mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -1,6 +1,49 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { WebAudioTransport } from "./webAudioTransport";
|
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", () => {
|
describe("WebAudioTransport", () => {
|
||||||
it("tracks play generation for async race prevention", () => {
|
it("tracks play generation for async race prevention", () => {
|
||||||
const transport = new WebAudioTransport();
|
const transport = new WebAudioTransport();
|
||||||
@@ -82,49 +125,6 @@ describe("WebAudioTransport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("schedulePlayback timing", () => {
|
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 () => {
|
it("starts in-progress clips immediately with correct buffer offset", async () => {
|
||||||
const { transport, mock, gen } = setupTransport(100);
|
const { transport, mock, gen } = setupTransport(100);
|
||||||
|
|
||||||
@@ -167,49 +167,6 @@ describe("WebAudioTransport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("playback rate", () => {
|
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 () => {
|
it("sets sourceNode.playbackRate.value when rate is provided", async () => {
|
||||||
const { transport, mock, gen } = setupTransport(100);
|
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 () => {
|
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);
|
const { transport, mock, gen } = setupTransport(100);
|
||||||
|
|
||||||
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
|
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2);
|
||||||
@@ -263,16 +216,34 @@ describe("WebAudioTransport", () => {
|
|||||||
expect(() => transport.setRate(2)).not.toThrow();
|
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 () => {
|
it("setRate clamps non-finite or non-positive values to 1", async () => {
|
||||||
const { transport, mock, gen } = setupTransport(100);
|
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);
|
transport.setRate(Number.NaN);
|
||||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||||
|
|
||||||
|
transport.setRate(2);
|
||||||
transport.setRate(0);
|
transport.setRate(0);
|
||||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||||
|
|
||||||
|
transport.setRate(2);
|
||||||
transport.setRate(-1);
|
transport.setRate(-1);
|
||||||
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
expect(mock.sourceNode.playbackRate.value).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,14 +112,8 @@ export class WebAudioTransport {
|
|||||||
this._rateAnchorComp = compositionTime;
|
this._rateAnchorComp = compositionTime;
|
||||||
|
|
||||||
if (elapsed >= 0) {
|
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);
|
sourceNode.start(0, elapsed + mediaStart);
|
||||||
} else {
|
} else {
|
||||||
// Future clip: composition time will reach `compositionStart` after
|
|
||||||
// `-elapsed` composition seconds, which is `-elapsed / r` wallclock.
|
|
||||||
const delay = -elapsed / safeRate;
|
const delay = -elapsed / safeRate;
|
||||||
sourceNode.start(scheduledAt + delay, mediaStart);
|
sourceNode.start(scheduledAt + delay, mediaStart);
|
||||||
}
|
}
|
||||||
@@ -146,18 +140,14 @@ export class WebAudioTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the playback rate of every currently-active source in place.
|
* Rebases the composition-time reference frame before swapping rate so
|
||||||
*
|
* `getTime()` stays continuous across the change. Sources scheduled to
|
||||||
* Also rebases the composition-time reference frame so `getTime()` keeps
|
* start in the future keep their original wallclock start time — callers
|
||||||
* returning the same value across the rate change (i.e. the clock doesn't
|
* that need rate-correct future starts should `stopAll()` and reschedule.
|
||||||
* 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 {
|
setRate(rate: number): void {
|
||||||
const safeRate = normalizeRate(rate);
|
const safeRate = normalizeRate(rate);
|
||||||
|
if (safeRate === this._rate) return;
|
||||||
if (this._ctx && !this._paused) {
|
if (this._ctx && !this._paused) {
|
||||||
this._rateAnchorComp = this.getTime();
|
this._rateAnchorComp = this.getTime();
|
||||||
this._rateAnchorCtx = this._ctx.currentTime;
|
this._rateAnchorCtx = this._ctx.currentTime;
|
||||||
|
|||||||
Reference in New Issue
Block a user