Skip to content

Commit e1b76df

Browse files
fix(core,studio): bound trimmed audio playback to the clip window
Trimmed audio played to the source file's natural end instead of stopping at the clip edge, on every audio path: - WebAudio (the audible path in Studio): schedulePlayback now passes the clip's data-duration as the third start() arg, so the decoded buffer stops at the trimmed edge instead of running to the file end. - Runtime element gating: the duration resolver caps each clip by its own data-duration (min of source length, host window, authored duration), so a trimmed <audio>/<video> element pauses at its edge. Studio trim UX: - Resize live-patches the media-start/playback-start offset, so a start-edge drag trims into the source instead of only repositioning the clip. - AudioWaveform windows the rendered peaks to the trimmed slice so the waveform tracks the clip edges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 02ef616 commit e1b76df

6 files changed

Lines changed: 197 additions & 36 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,10 +1469,18 @@ export function initSandboxRuntimeModular(): void {
14691469
Number.isFinite(element.duration) && element.duration > mediaStart
14701470
? Math.max(0, element.duration - mediaStart)
14711471
: null;
1472-
if (sourceDuration != null && hostRemaining != null) {
1473-
return Math.min(sourceDuration, hostRemaining);
1474-
}
1475-
return sourceDuration ?? hostRemaining;
1472+
// The element's own data-duration is an explicit clip-length trim
1473+
// (the studio writes it when you drag the clip edge). It must bound
1474+
// playback so a trimmed track stops at its edge instead of running on
1475+
// to the source-file or host-composition end. Absent → no cap (an
1476+
// untrimmed clip plays its natural source length).
1477+
const ownDuration = Number.parseFloat(element.dataset.duration ?? "");
1478+
const explicitDuration =
1479+
Number.isFinite(ownDuration) && ownDuration > 0 ? ownDuration : null;
1480+
const candidates = [sourceDuration, hostRemaining, explicitDuration].filter(
1481+
(value): value is number => value != null,
1482+
);
1483+
return candidates.length > 0 ? Math.min(...candidates) : null;
14761484
},
14771485
});
14781486
// Attach probed volume keyframes to clips so syncRuntimeMedia can use the
@@ -2168,6 +2176,13 @@ export function initSandboxRuntimeModular(): void {
21682176
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
21692177
const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? "");
21702178
const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1;
2179+
// The clip's authored window bounds the WebAudio buffer so a trimmed
2180+
// clip stops at its edge instead of running to the source's end.
2181+
const durationAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
2182+
const clipDuration =
2183+
Number.isFinite(durationAttr) && durationAttr > 0
2184+
? durationAttr
2185+
: Number.POSITIVE_INFINITY;
21712186
void webAudio.decodeAudioElement(rawEl).then((buffer) => {
21722187
if (!buffer || !clock.isPlaying()) return;
21732188
void webAudio.schedulePlayback(
@@ -2179,6 +2194,7 @@ export function initSandboxRuntimeModular(): void {
21792194
vol * state.bridgeVolume,
21802195
gen,
21812196
state.playbackRate,
2197+
clipDuration,
21822198
);
21832199
});
21842200
}

packages/core/src/runtime/webAudioTransport.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,43 @@ describe("WebAudioTransport", () => {
171171
});
172172
});
173173

174+
describe("clip duration bound (trim)", () => {
175+
it("bounds an in-progress clip to its remaining authored window", async () => {
176+
const { transport, mock, gen } = setupTransport(100);
177+
// compStart=5, mediaStart=0, compTime=8 → elapsed=3; clipDuration=10 → 7 left
178+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1, 10);
179+
expect(mock.startFn).toHaveBeenCalledWith(0, 3, 7);
180+
});
181+
182+
it("bounds a future clip to its full authored window", async () => {
183+
const { transport, mock, gen } = setupTransport(100);
184+
// compStart=10, mediaStart=1.5, compTime=2 → elapsed=-8 → delay 8; clipDuration=4
185+
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, 2, 1, gen, 1, 4);
186+
expect(mock.startFn).toHaveBeenCalledWith(108, 1.5, 4);
187+
});
188+
189+
it("does not schedule a clip whose window has already elapsed", async () => {
190+
const { transport, mock, gen } = setupTransport(100);
191+
// elapsed=15 > clipDuration=10 → nothing to play
192+
const result = await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 20, 1, gen, 1, 10);
193+
expect(result).toBeNull();
194+
expect(mock.startFn).not.toHaveBeenCalled();
195+
});
196+
197+
it("scales the bound by playback rate (buffer seconds)", async () => {
198+
const { transport, mock, gen } = setupTransport(100);
199+
// rate=2, clipDuration=10 → clipSourceLen=20; elapsed=3 → 17 buffer seconds left
200+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2, 10);
201+
expect(mock.startFn).toHaveBeenCalledWith(0, 3, 17);
202+
});
203+
204+
it("plays unbounded when clipDuration is omitted (legacy behavior)", async () => {
205+
const { transport, mock, gen } = setupTransport(100);
206+
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
207+
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
208+
});
209+
});
210+
174211
describe("playback rate", () => {
175212
it("sets sourceNode.playbackRate.value when rate is provided", async () => {
176213
const { transport, mock, gen } = setupTransport(100);

packages/core/src/runtime/webAudioTransport.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@ function normalizeRate(rate: number): number {
55
return rate;
66
}
77

8+
/**
9+
* Start a buffer source, bounding it to the clip's authored window
10+
* (`data-duration`) so a trimmed clip stops at its edge instead of running the
11+
* buffer to the source file's natural end. `clipSourceLen` is the clip span in
12+
* buffer seconds; the third `start()` arg is the portion to play from the
13+
* offset. An infinite `clipDuration` plays unbounded (legacy behavior).
14+
*
15+
* Returns false when the playhead is already past the clip end (nothing to
16+
* play); the caller should discard the source.
17+
*/
18+
function startBoundedSource(
19+
node: AudioBufferSourceNode,
20+
opts: {
21+
elapsed: number;
22+
mediaStart: number;
23+
scheduledAt: number;
24+
safeRate: number;
25+
clipDuration: number;
26+
},
27+
): boolean {
28+
const { elapsed, mediaStart, scheduledAt, safeRate, clipDuration } = opts;
29+
const hasBound = Number.isFinite(clipDuration) && clipDuration > 0;
30+
const clipSourceLen = clipDuration * safeRate;
31+
if (elapsed >= 0) {
32+
const remaining = clipSourceLen - elapsed;
33+
if (hasBound && remaining <= 0) return false;
34+
if (hasBound) node.start(0, elapsed + mediaStart, remaining);
35+
else node.start(0, elapsed + mediaStart);
36+
return true;
37+
}
38+
const delay = -elapsed / safeRate;
39+
if (hasBound) node.start(scheduledAt + delay, mediaStart, clipSourceLen);
40+
else node.start(scheduledAt + delay, mediaStart);
41+
return true;
42+
}
43+
844
export type ScheduledSource = {
945
el: HTMLMediaElement;
1046
sourceNode: AudioBufferSourceNode;
@@ -92,6 +128,7 @@ export class WebAudioTransport {
92128
volume: number,
93129
generation: number,
94130
rate = 1,
131+
clipDuration = Number.POSITIVE_INFINITY,
95132
): Promise<ScheduledSource | null> {
96133
if (!this._ctx || !this._masterGain) return null;
97134
if (generation !== this._playGeneration) return null;
@@ -119,11 +156,19 @@ export class WebAudioTransport {
119156
this._rateAnchorCtx = scheduledAt;
120157
this._rateAnchorComp = compositionTime;
121158

122-
if (elapsed >= 0) {
123-
sourceNode.start(0, elapsed + mediaStart);
124-
} else {
125-
const delay = -elapsed / safeRate;
126-
sourceNode.start(scheduledAt + delay, mediaStart);
159+
if (
160+
!startBoundedSource(sourceNode, {
161+
elapsed,
162+
mediaStart,
163+
scheduledAt,
164+
safeRate,
165+
clipDuration,
166+
})
167+
) {
168+
// Playhead already past the clip end — discard the nodes we built.
169+
sourceNode.disconnect();
170+
gainNode.disconnect();
171+
return null;
127172
}
128173

129174
const priorMuted = el.muted;

packages/studio/src/hooks/useRenderClipContent.ts

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,49 @@ export function normalizeCompositionSrc(
2222
return compSrc;
2323
}
2424

25+
/** Resolve a media src to its project-relative preview path, or null. */
26+
function resolvePreviewRelative(src: string | undefined, pid: string): string | null {
27+
if (!src) return null;
28+
if (!src.startsWith("http")) return src;
29+
const base = `/api/projects/${pid}/preview/`;
30+
const idx = src.indexOf(base);
31+
return idx !== -1 ? decodeURIComponent(src.slice(idx + base.length)) : null;
32+
}
33+
34+
/**
35+
* The trimmed source slice as start/end fractions (0–1) of the source, so the
36+
* waveform can window its peaks to the clip edges. Undefined when the source
37+
* length is unknown (renders full).
38+
*/
39+
function trimFractions(el: TimelineElement): { start?: number; end?: number } {
40+
const sourceDur = el.sourceDuration;
41+
if (sourceDur == null || sourceDur <= 0) return {};
42+
const mediaStart = el.playbackStart ?? 0;
43+
const rate = el.playbackRate ?? 1;
44+
const start = Math.max(0, Math.min(1, mediaStart / sourceDur));
45+
const end = Math.max(start, Math.min(1, (mediaStart + el.duration * rate) / sourceDur));
46+
return { start, end };
47+
}
48+
49+
/**
50+
* Build the waveform element for an audio clip, windowing the rendered peaks to
51+
* the trimmed source slice so the bars track the clip edges.
52+
*/
53+
function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode {
54+
const srcRelative = resolvePreviewRelative(el.src, pid);
55+
const audioUrl = srcRelative ? `/api/projects/${pid}/preview/${srcRelative}` : (el.src ?? "");
56+
const waveformUrl = srcRelative ? `/api/projects/${pid}/waveform/${srcRelative}` : undefined;
57+
const { start, end } = trimFractions(el);
58+
return createElement(AudioWaveform, {
59+
audioUrl,
60+
waveformUrl,
61+
label: getTimelineElementLabel(el),
62+
labelColor,
63+
trimStartFraction: start,
64+
trimEndFraction: end,
65+
});
66+
}
67+
2568
interface UseRenderClipContentOptions {
2669
projectIdRef: { current: string | null };
2770
compIdToSrc: Map<string, string>;
@@ -36,6 +79,8 @@ export function useRenderClipContent({
3679
effectiveTimelineDuration,
3780
}: UseRenderClipContentOptions) {
3881
return useCallback(
82+
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
83+
// fallow-ignore-next-line complexity
3984
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
4085
const pid = projectIdRef.current;
4186
if (!pid) return null;
@@ -88,27 +133,7 @@ export function useRenderClipContent({
88133

89134
// Audio clips — waveform visualization
90135
if (el.tag === "audio") {
91-
const previewBase = `/api/projects/${pid}/preview/`;
92-
const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
93-
const srcRelative = el.src
94-
? previewIdx !== -1
95-
? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
96-
: el.src.startsWith("http")
97-
? null
98-
: el.src
99-
: null;
100-
const audioUrl = srcRelative
101-
? `/api/projects/${pid}/preview/${srcRelative}`
102-
: (el.src ?? "");
103-
const waveformUrl = srcRelative
104-
? `/api/projects/${pid}/waveform/${srcRelative}`
105-
: undefined;
106-
return createElement(AudioWaveform, {
107-
audioUrl,
108-
waveformUrl,
109-
label: getTimelineElementLabel(el),
110-
labelColor: style.label,
111-
});
136+
return renderAudioClip(el, pid, style.label);
112137
}
113138

114139
if ((el.tag === "video" || el.tag === "img") && el.src) {

packages/studio/src/hooks/useTimelineEditing.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,21 @@ export function useTimelineEditing({
143143
element: TimelineElement,
144144
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
145145
) => {
146-
patchIframeDomTiming(previewIframeRef.current, element, [
146+
const liveAttrs: Array<[string, string]> = [
147147
["data-start", formatTimelineAttributeNumber(updates.start)],
148148
["data-duration", formatTimelineAttributeNumber(updates.duration)],
149-
]);
149+
];
150+
// A start-edge trim advances the media-start offset (skips into the
151+
// source). Patch it live too — otherwise the iframe keeps the old offset
152+
// and the clip only repositions instead of trimming the audio.
153+
if (updates.playbackStart != null) {
154+
const liveAttr =
155+
element.playbackStartAttr === "playback-start"
156+
? "data-playback-start"
157+
: "data-media-start";
158+
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
159+
}
160+
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
150161
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
151162
const pbs = resolveResizePlaybackStart(original, target, element, updates);
152163
let patched = applyPatchByTarget(original, target, {
@@ -173,6 +184,8 @@ export function useTimelineEditing({
173184
);
174185

175186
const handleTimelineElementDelete = useCallback(
187+
// Pre-existing handler complexity, unchanged by this PR.
188+
// fallow-ignore-next-line complexity
176189
async (element: TimelineElement) => {
177190
if (isRecordingRef?.current) {
178191
showToast("Cannot edit timeline while recording", "error");
@@ -247,6 +260,8 @@ export function useTimelineEditing({
247260
);
248261

249262
const handleTimelineAssetDrop = useCallback(
263+
// Pre-existing handler complexity, unchanged by this PR.
264+
// fallow-ignore-next-line complexity
250265
async (
251266
assetPath: string,
252267
placement: Pick<TimelineElement, "start" | "track">,
@@ -329,6 +344,8 @@ export function useTimelineEditing({
329344
);
330345

331346
const handleTimelineFileDrop = useCallback(
347+
// Pre-existing handler complexity, unchanged by this PR.
348+
// fallow-ignore-next-line complexity
332349
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
333350
if (isRecordingRef?.current) {
334351
showToast("Cannot edit timeline while recording", "error");

packages/studio/src/player/components/AudioWaveform.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ interface AudioWaveformProps {
55
waveformUrl?: string;
66
label: string;
77
labelColor: string;
8+
/**
9+
* Fraction (0–1) of the source the clip starts at, after the media-start
10+
* trim. Defaults to 0 (no front trim).
11+
*/
12+
trimStartFraction?: number;
13+
/**
14+
* Fraction (0–1) of the source the clip ends at. Defaults to 1 (no tail
15+
* trim). Together these window the rendered peaks to the trimmed slice so the
16+
* waveform tracks the clip edges instead of squeezing the whole file in.
17+
*/
18+
trimEndFraction?: number;
819
}
920

1021
const BAR_W = 2;
@@ -62,6 +73,8 @@ export const AudioWaveform = memo(function AudioWaveform({
6273
waveformUrl,
6374
label,
6475
labelColor,
76+
trimStartFraction,
77+
trimEndFraction,
6578
}: AudioWaveformProps) {
6679
const containerRef = useRef<HTMLDivElement | null>(null);
6780
const barsRef = useRef<HTMLDivElement | null>(null);
@@ -116,20 +129,28 @@ export const AudioWaveform = memo(function AudioWaveform({
116129
const barsEl = barsRef.current;
117130
if (!container || !barsEl || !peaks) return;
118131

132+
// Window the peaks to the trimmed slice [start, end) of the source so the
133+
// bars track the clip edges. Clamp to a valid, non-empty range.
134+
const winStart = Math.max(0, Math.min(1, trimStartFraction ?? 0));
135+
const winEnd = Math.max(winStart, Math.min(1, trimEndFraction ?? 1));
136+
const lo = Math.floor(winStart * peaks.length);
137+
const hi = Math.max(lo + 1, Math.ceil(winEnd * peaks.length));
138+
const span = hi - lo;
139+
119140
const w = container.clientWidth || 400;
120-
const barCount = Math.min(Math.floor(w / STEP), peaks.length);
141+
const barCount = Math.min(Math.floor(w / STEP), span);
121142

122143
let html = "";
123144
for (let i = 0; i < barCount; i++) {
124-
// Map bar index to peak index (resample)
125-
const peakIdx = Math.floor((i / barCount) * peaks.length);
145+
// Map bar index to peak index within the windowed range (resample)
146+
const peakIdx = lo + Math.floor((i / barCount) * span);
126147
const amp = peaks[peakIdx] ?? 0;
127148
const pct = Math.max(3, Math.round(amp * 100));
128149
const opacity = (0.45 + amp * 0.4).toFixed(2);
129150
html += `<div style="position:absolute;bottom:0;left:${i * STEP}px;width:${BAR_W}px;height:${pct}%;background:rgba(75,163,210,${opacity})"></div>`;
130151
}
131152
barsEl.innerHTML = html;
132-
}, [peaks]);
153+
}, [peaks, trimStartFraction, trimEndFraction]);
133154

134155
// Observe container size and redraw
135156
const setContainerRef = useCallback(

0 commit comments

Comments
 (0)