mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
- 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.
285 lines
9.6 KiB
TypeScript
285 lines
9.6 KiB
TypeScript
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();
|
|
expect(transport.currentGeneration()).toBe(0);
|
|
const gen1 = transport.startGeneration();
|
|
expect(gen1).toBe(1);
|
|
const gen2 = transport.startGeneration();
|
|
expect(gen2).toBe(2);
|
|
expect(transport.currentGeneration()).toBe(2);
|
|
});
|
|
|
|
it("getTime returns -1 when paused", () => {
|
|
const transport = new WebAudioTransport();
|
|
expect(transport.getTime()).toBe(-1);
|
|
});
|
|
|
|
it("isActive returns false initially", () => {
|
|
const transport = new WebAudioTransport();
|
|
expect(transport.isActive()).toBe(false);
|
|
});
|
|
|
|
it("stopAll restores el.muted to prior value", () => {
|
|
const transport = new WebAudioTransport();
|
|
const mockEl = { muted: false } as HTMLMediaElement;
|
|
const mockSource = {
|
|
el: mockEl,
|
|
sourceNode: { stop: vi.fn(), disconnect: vi.fn() } as unknown as AudioBufferSourceNode,
|
|
gainNode: { disconnect: vi.fn() } as unknown as GainNode,
|
|
compositionStart: 0,
|
|
mediaStart: 0,
|
|
scheduledAt: 0,
|
|
priorMuted: false,
|
|
};
|
|
// Simulate WebAudio taking over: el.muted was set to true
|
|
mockEl.muted = true;
|
|
(transport as unknown as { _activeSources: (typeof mockSource)[] })._activeSources = [
|
|
mockSource,
|
|
];
|
|
(transport as unknown as { _paused: boolean })._paused = false;
|
|
|
|
expect(transport.isActive()).toBe(true);
|
|
transport.stopAll();
|
|
expect(mockEl.muted).toBe(false);
|
|
expect(transport.isActive()).toBe(false);
|
|
});
|
|
|
|
it("stopAll restores el.muted=true when element was already muted", () => {
|
|
const transport = new WebAudioTransport();
|
|
const mockEl = { muted: true } as HTMLMediaElement;
|
|
const mockSource = {
|
|
el: mockEl,
|
|
sourceNode: { stop: vi.fn(), disconnect: vi.fn() } as unknown as AudioBufferSourceNode,
|
|
gainNode: { disconnect: vi.fn() } as unknown as GainNode,
|
|
compositionStart: 0,
|
|
mediaStart: 0,
|
|
scheduledAt: 0,
|
|
priorMuted: true,
|
|
};
|
|
(transport as unknown as { _activeSources: (typeof mockSource)[] })._activeSources = [
|
|
mockSource,
|
|
];
|
|
|
|
transport.stopAll();
|
|
expect(mockEl.muted).toBe(true);
|
|
});
|
|
|
|
it("stopAll called multiple times is safe (idempotent)", () => {
|
|
const transport = new WebAudioTransport();
|
|
transport.stopAll();
|
|
transport.stopAll();
|
|
expect(transport.isActive()).toBe(false);
|
|
});
|
|
|
|
it("destroy clears buffer cache and nulls context", () => {
|
|
const transport = new WebAudioTransport();
|
|
transport.destroy();
|
|
expect(transport.context).toBeNull();
|
|
expect(transport.isActive()).toBe(false);
|
|
});
|
|
|
|
describe("schedulePlayback timing", () => {
|
|
it("starts in-progress clips immediately with correct buffer offset", async () => {
|
|
const { transport, mock, gen } = setupTransport(100);
|
|
|
|
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
|
|
|
|
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
|
|
});
|
|
|
|
it("starts in-progress clips with mediaStart offset", async () => {
|
|
const { transport, mock, gen } = setupTransport(100);
|
|
|
|
await transport.schedulePlayback(mockEl, mockBuffer, 5, 2, 8, 1, gen);
|
|
|
|
expect(mock.startFn).toHaveBeenCalledWith(0, 5);
|
|
});
|
|
|
|
it("schedules future clips with delay instead of playing immediately", async () => {
|
|
const { transport, mock, gen } = setupTransport(100);
|
|
|
|
await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, 2, 1, gen);
|
|
|
|
expect(mock.startFn).toHaveBeenCalledWith(108, 0);
|
|
});
|
|
|
|
it("schedules future clips with correct mediaStart", async () => {
|
|
const { transport, mock, gen } = setupTransport(100);
|
|
|
|
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, 2, 1, gen);
|
|
|
|
expect(mock.startFn).toHaveBeenCalledWith(108, 1.5);
|
|
});
|
|
|
|
it("starts clips at exact composition start time immediately", async () => {
|
|
const { transport, mock, gen } = setupTransport(100);
|
|
|
|
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 5, 1, gen);
|
|
|
|
expect(mock.startFn).toHaveBeenCalledWith(0, 0);
|
|
});
|
|
});
|
|
|
|
describe("playback rate", () => {
|
|
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 () => {
|
|
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 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, 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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|