Skip to content

Commit a95e49d

Browse files
fix(core,player,studio): bound trimmed audio playback to the clip window (#1430)
* fix(player): bound the parent audio proxy to its clip window When iframe autoplay is blocked, audible playback is promoted to a parent-frame audio proxy. The proxy read the clip's data-start/data-duration once at adopt time and mirrorTime() only skipped (never paused) the element outside that window — so a trimmed/moved music clip kept playing the full source past its on-timeline end, even though the iframe element was correctly paused. Fix: the proxy keeps a reference to its source iframe element and re-reads data-start/data-duration each mirror tick (live trims/moves apply), pauses the proxy when the playhead leaves [start, start+duration), and resumes it when the playhead re-enters during parent-owned playback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * 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> * fix(player,core): gate proxy playback to the live clip window Review follow-ups on the parent-audio-proxy / WebAudio bound: - seekAll now re-reads live source bounds (_refreshEntryBounds) before gating, so a paused scrub right after a trim/move uses the current clip window instead of the adopt-time one. - playAll and clip adoption only start a proxy when the playhead is inside the clip's window (_playEntryIfActive), so bulk starts / promotion no longer blip audio for clips outside their window until the next tick. - The WebAudio buffer is now bounded by the host-composition window too (matching resolveDurationSeconds), so a sub-composition-nested clip stops at the same edge on the WebAudio and HTMLMedia paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core,player): reschedule bounded WebAudio on rate change; guard NaN bounds A bounded WebAudio source's wall-clock length is baked into start()'s duration arg (in buffer-sample seconds) at its scheduling rate. Mutating playbackRate in place on a later rate change does not rescale that bound, so a trimmed clip ends early (fast) or late (slow). setRate now reports whether the rate changed and exposes hasBoundedActiveSources(); the runtime stopAll()+reschedules active clips at the new rate when any bounded source is live. The per-clip schedule loop is extracted to a shared closure so play() and the rate path agree. Also guard _refreshEntryBounds against a non-numeric duration attribute parsing to NaN, which would make every window check false and let the proxy play past its clip end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 5c8b637 commit a95e49d

8 files changed

Lines changed: 373 additions & 79 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 76 additions & 32 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
@@ -1760,7 +1768,7 @@ export function initSandboxRuntimeModular(): void {
17601768
onSetPlaybackRate: (rate) => {
17611769
applyPlaybackRate(rate);
17621770
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
1763-
webAudio.setRate(state.playbackRate);
1771+
applyWebAudioRate();
17641772
},
17651773
onTick: () => {
17661774
if (state.tornDown || !clock.isPlaying()) return;
@@ -2133,6 +2141,67 @@ export function initSandboxRuntimeModular(): void {
21332141
};
21342142

21352143
// Player methods route through the TransportClock.
2144+
// Schedule WebAudio playback for every in-window audio clip, bounding each
2145+
// buffer to its clip window (own data-duration AND the remaining host
2146+
// composition window) so trimmed / sub-composition-nested clips stop at the
2147+
// same edge as the HTMLMedia path. Reused by play() and by the rate-change
2148+
// handler (a rate change can't rescale a bounded source in place).
2149+
const scheduleWebAudioForActiveClips = () => {
2150+
const gen = webAudio.startGeneration();
2151+
const audioEls = document.querySelectorAll("audio[data-start]");
2152+
for (const rawEl of audioEls) {
2153+
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
2154+
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
2155+
if (!Number.isFinite(compStart)) continue;
2156+
const mediaStart =
2157+
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
2158+
const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? "");
2159+
const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1;
2160+
const durationAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
2161+
let clipDuration =
2162+
Number.isFinite(durationAttr) && durationAttr > 0 ? durationAttr : Number.POSITIVE_INFINITY;
2163+
const compositionRoot = rawEl.closest("[data-composition-id]");
2164+
if (compositionRoot) {
2165+
const inheritedStart = resolveStartForElement(compositionRoot, 0);
2166+
const inheritedDuration = resolveDurationForElement(compositionRoot, {
2167+
includeAuthoredTimingAttrs: true,
2168+
});
2169+
if (inheritedDuration != null && inheritedDuration > 0) {
2170+
clipDuration = Math.min(
2171+
clipDuration,
2172+
Math.max(0, inheritedStart + inheritedDuration - compStart),
2173+
);
2174+
}
2175+
}
2176+
void webAudio.decodeAudioElement(rawEl).then((buffer) => {
2177+
if (!buffer || !clock.isPlaying()) return;
2178+
void webAudio.schedulePlayback(
2179+
rawEl,
2180+
buffer,
2181+
compStart,
2182+
mediaStart,
2183+
clock.now(),
2184+
vol * state.bridgeVolume,
2185+
gen,
2186+
state.playbackRate,
2187+
clipDuration,
2188+
);
2189+
});
2190+
}
2191+
};
2192+
2193+
// Apply a new playback rate to the WebAudio transport. Unbounded sources are
2194+
// rescaled in place; but a bounded source's window was baked into start()'s
2195+
// duration at its prior rate and can't be rescaled, so when one is active we
2196+
// stopAll()+reschedule at the new rate to keep trimmed clips ending on time.
2197+
const applyWebAudioRate = () => {
2198+
const changed = webAudio.setRate(state.playbackRate);
2199+
if (changed && webAudioReady && clock.isPlaying() && webAudio.hasBoundedActiveSources()) {
2200+
webAudio.stopAll();
2201+
scheduleWebAudioForActiveClips();
2202+
}
2203+
};
2204+
21362205
player.play = () => {
21372206
const tl = state.capturedTimeline;
21382207
if (clock.isPlaying()) return;
@@ -2157,32 +2226,7 @@ export function initSandboxRuntimeModular(): void {
21572226
// Schedule audio through WebAudio for sample-accurate timing.
21582227
// Falls back to HTMLMediaElement playback if WebAudio isn't ready
21592228
// or decoding fails (the syncRuntimeMedia path handles that).
2160-
if (webAudioReady) {
2161-
const gen = webAudio.startGeneration();
2162-
const audioEls = document.querySelectorAll("audio[data-start]");
2163-
for (const rawEl of audioEls) {
2164-
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
2165-
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
2166-
if (!Number.isFinite(compStart)) continue;
2167-
const mediaStart =
2168-
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
2169-
const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? "");
2170-
const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1;
2171-
void webAudio.decodeAudioElement(rawEl).then((buffer) => {
2172-
if (!buffer || !clock.isPlaying()) return;
2173-
void webAudio.schedulePlayback(
2174-
rawEl,
2175-
buffer,
2176-
compStart,
2177-
mediaStart,
2178-
clock.now(),
2179-
vol * state.bridgeVolume,
2180-
gen,
2181-
state.playbackRate,
2182-
);
2183-
});
2184-
}
2185-
}
2229+
if (webAudioReady) scheduleWebAudioForActiveClips();
21862230
runAdapters("play");
21872231
syncMediaForCurrentState();
21882232
postState(true);
@@ -2249,7 +2293,7 @@ export function initSandboxRuntimeModular(): void {
22492293
player.setPlaybackRate = (rate: number) => {
22502294
applyPlaybackRate(rate);
22512295
clock.setRate(state.playbackRate);
2252-
webAudio.setRate(state.playbackRate);
2296+
applyWebAudioRate();
22532297
};
22542298

22552299
// Sync clock duration from any captured timeline

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: 65 additions & 7 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;
@@ -13,6 +49,10 @@ export type ScheduledSource = {
1349
mediaStart: number;
1450
scheduledAt: number;
1551
priorMuted: boolean;
52+
// The clip had a finite window, so start() was given a fixed duration in
53+
// buffer-sample seconds. That bound can't be rescaled in place on a rate
54+
// change — callers must stopAll()+reschedule (see hasBoundedActiveSources).
55+
bounded: boolean;
1656
};
1757

1858
export class WebAudioTransport {
@@ -92,6 +132,7 @@ export class WebAudioTransport {
92132
volume: number,
93133
generation: number,
94134
rate = 1,
135+
clipDuration = Number.POSITIVE_INFINITY,
95136
): Promise<ScheduledSource | null> {
96137
if (!this._ctx || !this._masterGain) return null;
97138
if (generation !== this._playGeneration) return null;
@@ -119,11 +160,19 @@ export class WebAudioTransport {
119160
this._rateAnchorCtx = scheduledAt;
120161
this._rateAnchorComp = compositionTime;
121162

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

129178
const priorMuted = el.muted;
@@ -137,6 +186,7 @@ export class WebAudioTransport {
137186
mediaStart,
138187
scheduledAt,
139188
priorMuted,
189+
bounded: Number.isFinite(clipDuration) && clipDuration > 0,
140190
};
141191
this._activeSources.push(scheduled);
142192
this._paused = false;
@@ -163,9 +213,9 @@ export class WebAudioTransport {
163213
* start in the future keep their original wallclock start time — callers
164214
* that need rate-correct future starts should `stopAll()` and reschedule.
165215
*/
166-
setRate(rate: number): void {
216+
setRate(rate: number): boolean {
167217
const safeRate = normalizeRate(rate);
168-
if (safeRate === this._rate) return;
218+
if (safeRate === this._rate) return false;
169219
if (this._ctx && !this._paused) {
170220
this._rateAnchorComp = this.getTime();
171221
this._rateAnchorCtx = this._ctx.currentTime;
@@ -178,6 +228,14 @@ export class WebAudioTransport {
178228
swallow("webAudioTransport.setRate", err);
179229
}
180230
}
231+
return true;
232+
}
233+
234+
// A bounded source's wall-clock duration was baked into start()'s duration
235+
// arg at its original rate; a later rate change can't rescale it in place, so
236+
// the caller must stopAll()+reschedule to keep trimmed clips ending on time.
237+
hasBoundedActiveSources(): boolean {
238+
return this._activeSources.some((s) => s.bounded);
181239
}
182240

183241
stopAll(): void {

packages/player/src/parent-media.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import { describe, it, expect } from "vitest";
22
import { ParentMediaManager, type ProxyEntry } from "./parent-media";
33

4+
// A fake media element whose paused state is driven by play()/pause() stubs.
5+
function makeFakeAudio(initiallyPaused: boolean): HTMLMediaElement {
6+
const el = new Audio();
7+
let paused = initiallyPaused;
8+
Object.defineProperty(el, "paused", { get: () => paused });
9+
el.pause = () => {
10+
paused = true;
11+
};
12+
el.play = () => {
13+
paused = false;
14+
return Promise.resolve();
15+
};
16+
el.src = "https://example.test/music.mp3";
17+
return el;
18+
}
19+
420
function makeManager(overrides: Partial<{ isPaused: boolean; owner: "runtime" | "parent" }> = {}) {
521
const mgr = new ParentMediaManager({
622
dispatchEvent: () => {},
@@ -73,6 +89,39 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
7389
expect(mgr.entries).toHaveLength(0);
7490
});
7591

92+
it("pauses a proxy once the playhead passes the clip end (trimmed clip)", () => {
93+
const mgr = makeManager({ owner: "parent", isPaused: false });
94+
const el = makeFakeAudio(false); // already playing within the clip
95+
mgr.entries.push({ el, start: 0, duration: 5, driftSamples: 0 });
96+
97+
mgr.mirrorTime(3); // inside [0, 5) — stays playing
98+
expect(el.paused).toBe(false);
99+
100+
mgr.mirrorTime(6); // past the trimmed end — must pause
101+
expect(el.paused).toBe(true);
102+
});
103+
104+
it("re-reads the source element's live data-duration so trims bound the proxy", () => {
105+
const mgr = makeManager({ owner: "parent", isPaused: false });
106+
const source = new Audio();
107+
source.setAttribute("data-start", "0");
108+
source.setAttribute("data-duration", "30");
109+
// jsdom reports isConnected=false unless attached; attach it.
110+
document.body.appendChild(source);
111+
112+
const el = makeFakeAudio(false);
113+
mgr.entries.push({ el, start: 0, duration: 30, driftSamples: 0, source });
114+
115+
mgr.mirrorTime(20); // within 30 → playing
116+
expect(el.paused).toBe(false);
117+
118+
// User trims the clip to 10s; the proxy must pick it up and pause at 20s.
119+
source.setAttribute("data-duration", "10");
120+
mgr.mirrorTime(20);
121+
expect(el.paused).toBe(true);
122+
source.remove();
123+
});
124+
76125
it("does not duplicate or hijack a clip the composition already owns", () => {
77126
const mgr = makeManager();
78127
// The composition already adopted a clip with this URL.

0 commit comments

Comments
 (0)