Files
hyperframes/packages/core/src/runtime/webAudioTransport.test.ts
T
Miguel ÁngelandClaude Opus 4.6 38522f996e fix(runtime): schedule future WebAudio clips with delay instead of playing immediately
Clips whose compositionStart is ahead of the current timeline position
were starting immediately because sourceNode.start() always received
when=0. Use the AudioContext scheduling API to defer future clips:
sourceNode.start(ctx.currentTime + delay, mediaStart).

Closes #674

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 14:56:33 +00:00

165 lines
5.4 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { WebAudioTransport } from "./webAudioTransport";
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", () => {
function createMockAudioContext(currentTime = 100) {
const startFn = vi.fn();
const sourceNode = {
buffer: null as AudioBuffer | null,
start: startFn,
connect: vi.fn(),
};
const gainNode = {
gain: { value: 1 },
connect: 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);
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);
});
});
});