diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 1493b3d8c..91aa2b23b 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1589,6 +1589,7 @@ export function initSandboxRuntimeModular(): void { onSetPlaybackRate: (rate) => { applyPlaybackRate(rate); if (state.transportClock) state.transportClock.setRate(state.playbackRate); + webAudio.setRate(state.playbackRate); }, onEnablePickMode: () => picker.enablePickMode(), onDisablePickMode: () => picker.disablePickMode(), @@ -1858,6 +1859,7 @@ export function initSandboxRuntimeModular(): void { clock.now(), vol * state.bridgeVolume, gen, + state.playbackRate, ); }); } diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index d34ac7593..6bccdf734 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -86,12 +86,16 @@ describe("WebAudioTransport", () => { const startFn = vi.fn(); const sourceNode = { buffer: null as AudioBuffer | null, + playbackRate: { value: 1 }, start: startFn, + stop: vi.fn(), + disconnect: vi.fn(), connect: vi.fn(), }; const gainNode = { gain: { value: 1 }, connect: vi.fn(), + disconnect: vi.fn(), }; const masterGain = { gain: { value: 1 }, @@ -161,4 +165,149 @@ describe("WebAudioTransport", () => { expect(mock.startFn).toHaveBeenCalledWith(0, 0); }); }); + + describe("playback rate", () => { + function createMockAudioContext(currentTime = 100) { + const startFn = vi.fn(); + const sourceNode = { + buffer: null as AudioBuffer | null, + playbackRate: { value: 1 }, + start: startFn, + stop: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn(), + }; + const gainNode = { + gain: { value: 1 }, + connect: vi.fn(), + disconnect: vi.fn(), + }; + const masterGain = { + gain: { value: 1 }, + connect: vi.fn(), + }; + const ctx = { + currentTime, + state: "running", + resume: vi.fn(), + createBufferSource: vi.fn(() => sourceNode), + createGain: vi.fn(() => gainNode), + destination: {}, + close: vi.fn(), + }; + return { ctx, sourceNode, gainNode, masterGain, startFn }; + } + + function setupTransport(currentTime = 100) { + const transport = new WebAudioTransport(); + const mock = createMockAudioContext(currentTime); + (transport as unknown as { _ctx: unknown })._ctx = mock.ctx; + (transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain; + const gen = transport.startGeneration(); + return { transport, mock, gen }; + } + + const mockBuffer = {} as AudioBuffer; + const mockEl = { muted: false } as HTMLMediaElement; + + it("sets sourceNode.playbackRate.value when rate is provided", async () => { + const { transport, mock, gen } = setupTransport(100); + + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); + + expect(mock.sourceNode.playbackRate.value).toBe(2); + }); + + it("defaults rate to 1 when not provided", async () => { + const { transport, mock, gen } = setupTransport(100); + + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen); + + expect(mock.sourceNode.playbackRate.value).toBe(1); + }); + + it("scales delay by rate for future clips so they fire at the right wallclock", async () => { + const { transport, mock, gen } = setupTransport(100); + + // compStart=10, compositionTime=2, rate=2 → 8s of comp time = 4s wallclock + await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, 2, 1, gen, 2); + + expect(mock.startFn).toHaveBeenCalledWith(104, 0); + }); + + it("keeps in-progress buffer offset at elapsed + mediaStart regardless of rate", async () => { + // Audio that has been playing for `elapsed` composition seconds at rate r: + // wallclock elapsed = elapsed / r + // buffer advanced = (elapsed / r) × r = elapsed + // so the buffer offset is `elapsed + mediaStart` for any rate. + const { transport, mock, gen } = setupTransport(100); + + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); + + expect(mock.startFn).toHaveBeenCalledWith(0, 3); + }); + + it("setRate updates active sources in place", async () => { + const { transport, mock, gen } = setupTransport(100); + + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1); + expect(mock.sourceNode.playbackRate.value).toBe(1); + + transport.setRate(2); + + expect(mock.sourceNode.playbackRate.value).toBe(2); + }); + + it("setRate before any sources are scheduled does not throw", () => { + const transport = new WebAudioTransport(); + expect(() => transport.setRate(2)).not.toThrow(); + }); + + it("setRate clamps non-finite or non-positive values to 1", async () => { + const { transport, mock, gen } = setupTransport(100); + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1); + + transport.setRate(Number.NaN); + expect(mock.sourceNode.playbackRate.value).toBe(1); + + transport.setRate(0); + expect(mock.sourceNode.playbackRate.value).toBe(1); + + transport.setRate(-1); + expect(mock.sourceNode.playbackRate.value).toBe(1); + }); + + it("getTime advances at the configured rate", async () => { + const { transport, mock, gen } = setupTransport(100); + + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); + + // At schedule time, ctx.currentTime=100, compositionTime=8. + expect(transport.getTime()).toBeCloseTo(8, 10); + + // Advance the audio-context clock by 0.5 wallclock seconds; at rate=2, + // composition time should have advanced 1s. + mock.ctx.currentTime = 100.5; + expect(transport.getTime()).toBeCloseTo(9, 10); + }); + + it("getTime tracks composition time after a mid-playback setRate", async () => { + const { transport, mock, gen } = setupTransport(100); + + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1); + expect(transport.getTime()).toBeCloseTo(8, 10); + + // 0.5s passes at rate=1 → composition time = 8.5 + mock.ctx.currentTime = 100.5; + expect(transport.getTime()).toBeCloseTo(8.5, 10); + + // Bump rate to 2 — composition time should NOT jump. + transport.setRate(2); + expect(transport.getTime()).toBeCloseTo(8.5, 10); + + // Another 0.5s wallclock at rate=2 → composition time = 9.5 + mock.ctx.currentTime = 101; + expect(transport.getTime()).toBeCloseTo(9.5, 10); + }); + }); }); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 14c543063..932236f82 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -1,5 +1,10 @@ import { swallow } from "./diagnostics"; +function normalizeRate(rate: number): number { + if (!Number.isFinite(rate) || rate <= 0) return 1; + return rate; +} + export type ScheduledSource = { el: HTMLMediaElement; sourceNode: AudioBufferSourceNode; @@ -15,7 +20,12 @@ export class WebAudioTransport { private _bufferCache = new Map(); private _activeSources: ScheduledSource[] = []; private _masterGain: GainNode | null = null; - private _scheduleOffset = 0; + // Composition-time reference frame: at AudioContext time `_rateAnchorCtx`, + // composition time was `_rateAnchorComp`, and time has been advancing at + // `_rate` composition-seconds per wallclock-second since. + private _rateAnchorCtx = 0; + private _rateAnchorComp = 0; + private _rate = 1; private _paused = true; private _playGeneration = 0; @@ -36,7 +46,7 @@ export class WebAudioTransport { getTime(): number { if (!this._ctx || this._paused) return -1; - return this._ctx.currentTime - this._scheduleOffset; + return this._rateAnchorComp + (this._ctx.currentTime - this._rateAnchorCtx) * this._rate; } async decodeAudioElement(el: HTMLMediaElement): Promise { @@ -73,6 +83,7 @@ export class WebAudioTransport { compositionTime: number, volume: number, generation: number, + rate = 1, ): Promise { if (!this._ctx || !this._masterGain) return null; if (generation !== this._playGeneration) return null; @@ -83,8 +94,11 @@ export class WebAudioTransport { } if (generation !== this._playGeneration) return null; + const safeRate = normalizeRate(rate); + const sourceNode = this._ctx.createBufferSource(); sourceNode.buffer = buffer; + sourceNode.playbackRate.value = safeRate; const gainNode = this._ctx.createGain(); gainNode.gain.value = volume; @@ -93,12 +107,20 @@ export class WebAudioTransport { const elapsed = compositionTime - compositionStart; const scheduledAt = this._ctx.currentTime; - this._scheduleOffset = scheduledAt - compositionTime; + this._rate = safeRate; + this._rateAnchorCtx = scheduledAt; + this._rateAnchorComp = compositionTime; if (elapsed >= 0) { + // Audio that has already been "playing" for `elapsed` composition + // seconds at rate r has advanced exactly `elapsed` buffer seconds — + // wallclock elapsed is `elapsed / r` and the source plays at rate r, + // so buffer advance = (elapsed / r) × r = elapsed. sourceNode.start(0, elapsed + mediaStart); } else { - const delay = -elapsed; + // Future clip: composition time will reach `compositionStart` after + // `-elapsed` composition seconds, which is `-elapsed / r` wallclock. + const delay = -elapsed / safeRate; sourceNode.start(scheduledAt + delay, mediaStart); } @@ -123,6 +145,33 @@ export class WebAudioTransport { } } + /** + * Change the playback rate of every currently-active source in place. + * + * Also rebases the composition-time reference frame so `getTime()` keeps + * returning the same value across the rate change (i.e. the clock doesn't + * jump, it just advances at a different slope from here on). + * + * Note: sources scheduled to start in the future via `sourceNode.start(when)` + * keep their original wallclock start time. Callers that need rate-correct + * future-start times should `stopAll()` and reschedule. + */ + setRate(rate: number): void { + const safeRate = normalizeRate(rate); + if (this._ctx && !this._paused) { + this._rateAnchorComp = this.getTime(); + this._rateAnchorCtx = this._ctx.currentTime; + } + this._rate = safeRate; + for (const source of this._activeSources) { + try { + source.sourceNode.playbackRate.value = safeRate; + } catch (err) { + swallow("webAudioTransport.setRate", err); + } + } + } + stopAll(): void { for (const source of this._activeSources) { try {