refactor(core): tighten WebAudio rate fix per review

- Hoist duplicated test mock helpers (createMockAudioContext / setupTransport /
  mockBuffer / mockEl) from the two describe blocks to module scope.
- Drop redundant math-derivation comments in schedulePlayback; the dedicated
  rate-aware tests are the canonical proof.
- Tighten setRate JSDoc.
- Add no-op guard in setRate when the new rate equals the current rate, so a
  duplicate set-playback-rate postMessage doesn't re-anchor or walk active
  sources for nothing.
- Add a regression test for the no-op guard, and strengthen the clamp test
  to schedule at rate=2 first so the clamp-to-1 assertion is non-vacuous.
This commit is contained in:
James
2026-05-11 14:11:54 +00:00
parent 89ee1e36d7
commit 117029a719
2 changed files with 67 additions and 106 deletions
@@ -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);
});
+5 -15
View File
@@ -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;