fix: separate audio clip extension from repitch stretch#676
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the audio-clip model and timeline behavior to distinguish non-destructive clip extension (including leading silence) from intentional repitch-stretch (Shift+edge drag), while also improving waveform fidelity and ensuring rendering/scheduling focus on the audible region.
Changes:
- Adds
contentOffsetand aclipAudiohelper layer to compute audible start/duration, source spans, and waveform layout. - Reworks timeline resize interactions to make normal edge drags extend/trim without stretching, reserving Shift+drag for repitch stretch.
- Increases persisted waveform peak density to 1024 and updates waveform rendering/playback scheduling to reflect only audible content.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/clipWaveform.test.tsx | Adds unit coverage for waveform inset behavior with contentOffset and stretched rendering behavior. |
| tests/unit/clipResizeModifiers.test.tsx | Adds unit coverage for leading-silence extension and Shift repitch-stretch semantics. |
| tests/unit/clipResizeAndFadeVisuals.test.tsx | Updates regression assertion for wider resize handles (16px). |
| tests/unit/clipAudio.test.ts | Adds unit tests for new clipAudio helper computations (audible window, waveform layout, stretch). |
| tests/unit/bounceInPlaceService.test.ts | Updates expected waveform peak count from 200 to 1024. |
| tests/e2e/audio-clip-resize.spec.ts | Adds Playwright coverage for extension vs Shift-stretch and waveform positioning. |
| src/utils/clipAudio.ts | Introduces shared constants + helper functions for audible window/layout calculations. |
| src/types/project.ts | Extends Clip type with contentOffset. |
| src/store/projectStore.ts | Ensures newly created clips can carry audioDuration/audioOffset/contentOffset/stretch fields. |
| src/services/stemSeparation.ts | Uses shared peak-count constant when generating waveform peaks. |
| src/services/sequencerBounce.ts | Uses shared peak-count constant when generating waveform peaks. |
| src/services/generationPipeline.ts | Uses shared peak-count constant across generation/repaint/cover/vocal2bgm peak computation. |
| src/services/freezeTrack.ts | Uses shared peak-count constant when flattening tracks to audio. |
| src/services/clipConsolidation.ts | Uses shared peak-count constant when generating consolidated clip peaks. |
| src/services/bounceInPlace.ts | Uses shared peak-count constant when generating bounce peaks. |
| src/hooks/useTransport.ts | Schedules playback/scrub based on audible window rather than full clip container. |
| src/hooks/useRecording.ts | Uses shared peak-count constant for recorded clip/take peak computation. |
| src/hooks/useAudioImport.ts | Uses shared peak-count constant across audio import paths. |
| src/components/timeline/ClipWaveform.tsx | Renders denser waveform columns only in audible region; integrates contentOffset/stretch layout. |
| src/components/timeline/ClipBlock.tsx | Implements resize semantics + handle sizing; adds lazy waveform upgrade for legacy peak density. |
| docs/plans/fix-audio-clip-extension-repitch-stretch.md | Adds an implementation plan doc describing the approach and verification steps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| const barSpacing = contentWidth / peakSlice.numBars; | ||
| const barWidth = Math.max(barSpacing * 0.7, 0.5); | ||
| const columnCount = Math.max(1, Math.floor(waveformLayout.widthPx)); |
There was a problem hiding this comment.
columnCount scales with rendered pixel width (Math.floor(waveformLayout.widthPx)), so at high zoom levels / long clips this can create tens or hundreds of thousands of <rect> nodes and severely degrade UI performance. Please cap the rendered column count (e.g., to a reasonable max and/or to peaks.length) and downsample peaks accordingly so waveform rendering remains bounded.
| const columnCount = Math.max(1, Math.floor(waveformLayout.widthPx)); | |
| const MAX_WAVEFORM_COLUMNS = 4000; | |
| const columnCount = Math.max( | |
| 1, | |
| Math.min( | |
| Math.floor(waveformLayout.widthPx), | |
| MAX_WAVEFORM_COLUMNS, | |
| peakSlice.numBars, | |
| peaks.length, | |
| ), | |
| ); |
| if ((mode === 'resize-left' || mode === 'resize-right') && dragRef.current && !ev.shiftKey) { | ||
| const currentClip = useProjectStore.getState().getClipById(clip.id); | ||
| if (currentClip) { | ||
| if (mode === 'resize-left') { | ||
| const trimmedAudioLeft = (currentClip.audioOffset ?? 0) > origAudioOffset + CLIP_DRAG_EPSILON; | ||
| if (trimmedAudioLeft) { | ||
| void snapClipEdgeToZeroCrossing(clip.id, 'left'); | ||
| } | ||
| } else { | ||
| const trimmedAudioRight = getClipAudibleSourceEnd(currentClip) < origAudibleSourceEnd - CLIP_DRAG_EPSILON; | ||
| if (trimmedAudioRight) { | ||
| void snapClipEdgeToZeroCrossing(clip.id, 'right'); | ||
| } |
There was a problem hiding this comment.
The decision to zero-crossing snap is gated on !ev.shiftKey in the mouseup handler. If the user performs a Shift-resize (repitch stretch) but releases Shift before releasing the mouse button, this condition becomes true and the code may incorrectly apply zero-crossing snapping to a stretch operation. Consider tracking whether the resize was in stretch mode during the drag (e.g., via a ref updated in mousemove, or by checking the clip’s current stretchMode/timeStretchRate) rather than relying on mouseup’s modifier state.
| if ((mode === 'resize-left' || mode === 'resize-right') && dragRef.current && !ev.shiftKey) { | |
| const currentClip = useProjectStore.getState().getClipById(clip.id); | |
| if (currentClip) { | |
| if (mode === 'resize-left') { | |
| const trimmedAudioLeft = (currentClip.audioOffset ?? 0) > origAudioOffset + CLIP_DRAG_EPSILON; | |
| if (trimmedAudioLeft) { | |
| void snapClipEdgeToZeroCrossing(clip.id, 'left'); | |
| } | |
| } else { | |
| const trimmedAudioRight = getClipAudibleSourceEnd(currentClip) < origAudibleSourceEnd - CLIP_DRAG_EPSILON; | |
| if (trimmedAudioRight) { | |
| void snapClipEdgeToZeroCrossing(clip.id, 'right'); | |
| } | |
| if ((mode === 'resize-left' || mode === 'resize-right') && dragRef.current) { | |
| const currentClip = useProjectStore.getState().getClipById(clip.id); | |
| if (currentClip) { | |
| // Determine if this resize represents a stretch operation; in that case, skip zero-crossing snapping. | |
| const isStretchResize = | |
| ((currentClip as any).stretchMode === 'repitch') || | |
| (typeof (currentClip as any).timeStretchRate === 'number' && | |
| (currentClip as any).timeStretchRate !== 1); | |
| if (!isStretchResize) { | |
| if (mode === 'resize-left') { | |
| const trimmedAudioLeft = (currentClip.audioOffset ?? 0) > origAudioOffset + CLIP_DRAG_EPSILON; | |
| if (trimmedAudioLeft) { | |
| void snapClipEdgeToZeroCrossing(clip.id, 'left'); | |
| } | |
| } else { | |
| const trimmedAudioRight = getClipAudibleSourceEnd(currentClip) < origAudibleSourceEnd - CLIP_DRAG_EPSILON; | |
| if (trimmedAudioRight) { | |
| void snapClipEdgeToZeroCrossing(clip.id, 'right'); | |
| } | |
| } |
| audioOffset: clip.isolatedAudioKey | ||
| ? (clip.audioOffset ?? 0) | ||
| : clip.startTime + (clip.audioOffset ?? 0), | ||
| : audibleStartTime, |
There was a problem hiding this comment.
In timeline scrub mode, audioOffset for cumulative buffers is currently set to audibleStartTime, which ignores clip.audioOffset. This will make scrub preview read the wrong portion of the buffer for slipped/cropped clips that don't have an isolatedAudioKey. Consider incorporating the clip’s audioOffset into the buffer offset (relative to the audible start) so getScrubSourceOffset stays consistent with slip/crop behavior.
| : audibleStartTime, | |
| : audibleStartTime + (clip.audioOffset ?? 0), |
Summary
contentOffsetfor silent head paddingShift+ edge drag for repitch stretch and keep ordinary left/right extension non-destructiveProblem
What changed
contentOffsetto clip state and a newsrc/utils/clipAudio.tshelper layerClipBlockresize semantics, handle hit targets, zero-crossing snap conditions, and lazy waveform upgrades for legacy clipsClipWaveformto render dense waveform columns only inside the audible spanVerification
npx tsc --noEmitnpx vitest run tests/unit/clipAudio.test.ts tests/unit/clipWaveform.test.tsx tests/unit/clipResizeModifiers.test.tsx tests/unit/clipResizeAndFadeVisuals.test.tsxnpm run test:e2e -- tests/e2e/audio-clip-resize.spec.tsnpm testnpm run buildCloses #674
Closes #675