Skip to content

fix: separate audio clip extension from repitch stretch#676

Merged
ChuxiJ merged 4 commits intomainfrom
fix/issue-675
Mar 21, 2026
Merged

fix: separate audio clip extension from repitch stretch#676
ChuxiJ merged 4 commits intomainfrom
fix/issue-675

Conversation

@ChuxiJ
Copy link

@ChuxiJ ChuxiJ commented Mar 21, 2026

Summary

  • separate ordinary clip extension from repitch stretch by adding contentOffset for silent head padding
  • reserve Shift + edge drag for repitch stretch and keep ordinary left/right extension non-destructive
  • upgrade audio clip waveform density and render only the audible region so blank head/tail stays visually empty

Problem

  • waveform previews were too coarse to align against neighboring tracks
  • ordinary edge dragging visually behaved like audio stretching instead of clip extension
  • leftward extension could not represent leading silence, and rightward extension made the waveform drift
  • resize handles were too narrow to grab reliably

What changed

  • added contentOffset to clip state and a new src/utils/clipAudio.ts helper layer
  • reworked ClipBlock resize semantics, handle hit targets, zero-crossing snap conditions, and lazy waveform upgrades for legacy clips
  • updated ClipWaveform to render dense waveform columns only inside the audible span
  • updated transport/scrub scheduling to schedule only the audible window instead of the whole clip container
  • raised persisted waveform peak density from 200 to 1024 across audio clip creation/bounce/consolidation paths
  • added a plan doc plus unit and Playwright regression coverage for silent extension vs Shift stretch

Verification

  • npx tsc --noEmit
  • npx vitest run tests/unit/clipAudio.test.ts tests/unit/clipWaveform.test.tsx tests/unit/clipResizeModifiers.test.tsx tests/unit/clipResizeAndFadeVisuals.test.tsx
  • npm run test:e2e -- tests/e2e/audio-clip-resize.spec.ts
  • npm test
  • npm run build

Closes #674
Closes #675

Copilot AI review requested due to automatic review settings March 21, 2026 14:59
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 contentOffset and a clipAudio helper 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));
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
),
);

Copilot uses AI. Check for mistakes.
Comment on lines +512 to +524
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');
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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');
}
}

Copilot uses AI. Check for mistakes.
audioOffset: clip.isolatedAudioKey
? (clip.audioOffset ?? 0)
: clip.startTime + (clip.audioOffset ?? 0),
: audibleStartTime,
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
: audibleStartTime,
: audibleStartTime + (clip.audioOffset ?? 0),

Copilot uses AI. Check for mistakes.
@ChuxiJ ChuxiJ merged commit 8073646 into main Mar 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants