diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 6bccdf734..1dc6caf8f 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -1,6 +1,49 @@ import { describe, it, expect, vi } from "vitest"; import { WebAudioTransport } from "./webAudioTransport"; +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; + describe("WebAudioTransport", () => { it("tracks play generation for async race prevention", () => { const transport = new WebAudioTransport(); @@ -82,49 +125,6 @@ describe("WebAudioTransport", () => { }); describe("schedulePlayback timing", () => { - 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("starts in-progress clips immediately with correct buffer offset", async () => { const { transport, mock, gen } = setupTransport(100); @@ -167,49 +167,6 @@ describe("WebAudioTransport", () => { }); 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); @@ -236,10 +193,6 @@ describe("WebAudioTransport", () => { }); 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); @@ -263,16 +216,34 @@ describe("WebAudioTransport", () => { expect(() => transport.setRate(2)).not.toThrow(); }); + it("setRate is a no-op when the rate is unchanged", async () => { + const { transport, mock, gen } = setupTransport(100); + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); + + mock.ctx.currentTime = 100.5; + const timeBefore = transport.getTime(); + transport.setRate(2); + const timeAfter = transport.getTime(); + + expect(timeAfter).toBe(timeBefore); + // No re-anchor, so the next 0.5s of wallclock still maps to 1s of comp time. + mock.ctx.currentTime = 101; + expect(transport.getTime()).toBeCloseTo(10, 10); + }); + 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); + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); + expect(mock.sourceNode.playbackRate.value).toBe(2); transport.setRate(Number.NaN); expect(mock.sourceNode.playbackRate.value).toBe(1); + transport.setRate(2); transport.setRate(0); expect(mock.sourceNode.playbackRate.value).toBe(1); + transport.setRate(2); transport.setRate(-1); expect(mock.sourceNode.playbackRate.value).toBe(1); }); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 932236f82..baa1113bc 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -112,14 +112,8 @@ export class WebAudioTransport { 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 { - // 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); } @@ -146,18 +140,14 @@ 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. + * Rebases the composition-time reference frame before swapping rate so + * `getTime()` stays continuous across the change. Sources scheduled to + * start in the future keep their original wallclock start time — callers + * that need rate-correct future starts should `stopAll()` and reschedule. */ setRate(rate: number): void { const safeRate = normalizeRate(rate); + if (safeRate === this._rate) return; if (this._ctx && !this._paused) { this._rateAnchorComp = this.getTime(); this._rateAnchorCtx = this._ctx.currentTime;