UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

881 lines (826 loc) • 30.6 kB
import { afterEach, describe, expect, it, vi } from "vitest"; import { WORKLET_SOURCE, createPcmPlayer, createResampler, decodePcm16, encodePcm16, openMicrophone, readLevel, } from "./audio"; class FakeTrack { enabled = true; stop = vi.fn(); } class FakeSource { buffer: unknown = null; onended: (() => void) | null = null; connect = vi.fn(); start = vi.fn(); stop = vi.fn(); } class FakeWorklet { static instances: FakeWorklet[] = []; port: { onmessage: ((event: { data: unknown }) => void) | null } = { onmessage: null, }; connect = vi.fn(); disconnect = vi.fn(); options: unknown; constructor(_context: unknown, _name: string, options?: unknown) { this.options = options; FakeWorklet.instances.push(this); } } class FakeContext { static instances: FakeContext[] = []; static workletSupported = true; currentTime = 0; state = "running"; sampleRate: number; sources: FakeSource[] = []; destination = {}; static addModule: (url: string) => Promise<void> = async () => {}; audioWorklet = FakeContext.workletSupported ? { addModule: vi.fn((url: string) => FakeContext.addModule(url)) } : undefined; mediaSource = { connect: vi.fn(), disconnect: vi.fn() }; processor = { connect: vi.fn(), disconnect: vi.fn(), onaudioprocess: null as ((event: unknown) => void) | null, }; analyser = { fftSize: 0, connect: vi.fn(), getByteTimeDomainData: vi.fn() }; constructor(options?: { sampleRate: number }) { this.sampleRate = options?.sampleRate ?? 48000; FakeContext.instances.push(this); } createAnalyser = vi.fn(() => this.analyser); createMediaStreamSource = vi.fn(() => this.mediaSource); createScriptProcessor = vi.fn( (_bufferSize: number, _inputChannels: number, _outputChannels: number) => this.processor, ); createBuffer = vi.fn((_channels: number, length: number, rate: number) => ({ duration: length / rate, copyToChannel: vi.fn(), })); createBufferSource = vi.fn(() => { const source = new FakeSource(); this.sources.push(source); return source; }); resume: () => Promise<void> = vi.fn(async () => {}); close = vi.fn(async () => { if (this.state === "closed") throw new DOMException( "AudioContext is already closed", "InvalidStateError", ); this.state = "closed"; }); } const installBrowser = () => { const track = new FakeTrack(); const getUserMedia = vi.fn(async () => ({ getTracks: () => [track], getAudioTracks: () => [track], })); const createObjectURL = vi.fn(() => "blob:worklet"); const revokeObjectURL = vi.fn(); vi.stubGlobal("AudioContext", FakeContext); vi.stubGlobal("AudioWorkletNode", FakeWorklet); vi.stubGlobal("URL", { createObjectURL, revokeObjectURL }); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); return { track, getUserMedia, createObjectURL, revokeObjectURL }; }; const chunk = (length: number) => encodePcm16(new Float32Array(length).fill(0.1)); describe("pcm", () => { it("round-trips samples through little-endian int16", () => { const samples = Float32Array.from({ length: 1000 }, (_, i) => Math.sin(i / 10), ); const decoded = decodePcm16(encodePcm16(samples)); expect(decoded.length).toBe(1000); for (let i = 0; i < samples.length; i++) { expect(Math.abs(decoded[i]! - samples[i]!)).toBeLessThan(2 / 32768); } }); it("clamps out-of-range samples", () => { const decoded = decodePcm16(encodePcm16(Float32Array.from([2, -2]))); expect(decoded[0]).toBeCloseTo(1, 3); expect(decoded[1]).toBe(-1); }); it("round-trips an empty buffer", () => { expect(encodePcm16(new Float32Array(0))).toBe(""); expect(decodePcm16("").length).toBe(0); }); it("encodes buffers longer than one fromCharCode chunk", () => { const samples = new Float32Array(40000).fill(0.5); expect(decodePcm16(encodePcm16(samples)).length).toBe(40000); }); }); describe("level", () => { const analyser = (fill: (i: number) => number) => ({ fftSize: 256, getByteTimeDomainData: (data: Uint8Array) => { for (let i = 0; i < data.length; i++) data[i] = fill(i); }, }) as unknown as AnalyserNode; it("is 0 for silence", () => { expect(readLevel(analyser(() => 128))).toBe(0); }); it("is 1 for a full-swing square wave", () => { expect(readLevel(analyser((i) => (i % 2 ? 255 : 0)))).toBe(1); }); it("never exceeds 1", () => { expect(readLevel(analyser(() => 255))).toBeLessThanOrEqual(1); }); }); const ramp = (start: number, length: number, scale = 1) => Float32Array.from({ length }, (_, i) => (start + i) * scale); const tone = ( frequency: number, sampleRate: number, start: number, length: number, ) => Float32Array.from({ length }, (_, i) => Math.sin((2 * Math.PI * frequency * (start + i)) / sampleRate), ); const rms = (samples: number[]) => Math.sqrt( samples.reduce((sum, sample) => sum + sample * sample, 0) / samples.length, ); const loadWorklet = (sampleRate: number, targetRate: number) => { const posted: Float32Array[] = []; class AudioWorkletProcessor { port = { postMessage: (data: Float32Array) => posted.push(data) }; } let Processor: | (new (options: { processorOptions: { targetRate: number } }) => { process: (inputs: Float32Array[][]) => boolean; }) | undefined; new Function( "AudioWorkletProcessor", "sampleRate", "registerProcessor", WORKLET_SOURCE, )(AudioWorkletProcessor, sampleRate, (_: string, cls: typeof Processor) => { Processor = cls; }); const processor = new Processor!({ processorOptions: { targetRate } }); return { process: (...channels: Float32Array[]) => processor.process([channels]), posted, }; }; const resampleWithWorklet = (frequency: number, sampleRate = 48000) => { const worklet = loadWorklet(sampleRate, 16000); const input = tone(frequency, sampleRate, 0, sampleRate); for (let start = 0; start < input.length; start += 128) worklet.process(input.subarray(start, start + 128)); return worklet.posted.flatMap((data) => [...data]); }; describe("resampler", () => { it.each([ [48000, 1365], [44100, 1486], ])( "keeps phase and filter state across 2048-frame buffers at %i Hz", (rate, count) => { const ratio = rate / 16000; const input = ramp(0, 4096); const expected = [...createResampler(ratio)(input)]; const resample = createResampler(ratio); const out = [...resample(ramp(0, 2048)), ...resample(ramp(2048, 2048))]; expect(out).toHaveLength(count); expect(out).toEqual(expected); }, ); it("interpolates across the boundary between buffers", () => { const input = ramp(0, 6); const expected = [...createResampler(2.5)(input)]; const resample = createResampler(2.5); expect([...resample(ramp(0, 3)), ...resample(ramp(3, 3))]).toEqual( expected, ); }); it("ignores empty buffers", () => { const resample = createResampler(3); expect(resample(new Float32Array(0))).toHaveLength(0); const out = [...resample(ramp(0, 5))]; expect(out).toHaveLength(2); expect(out).toEqual([...createResampler(3)(ramp(0, 5))]); }); it("attenuates tones above Nyquist while retaining speech-band tones in both paths", () => { for (const sampleRate of [48000, 44100]) { const ratio = sampleRate / 16000; const scriptHigh = [ ...createResampler(ratio)(tone(12000, sampleRate, 0, sampleRate)), ]; const scriptSpeech = [ ...createResampler(ratio)(tone(4000, sampleRate, 0, sampleRate)), ]; const workletHigh = resampleWithWorklet(12000, sampleRate); const workletSpeech = resampleWithWorklet(4000, sampleRate); for (const { high, speech } of [ { high: scriptHigh, speech: scriptSpeech }, { high: workletHigh, speech: workletSpeech }, ]) { expect(rms(high.slice(100))).toBeLessThan(0.05); expect(rms(speech.slice(100))).toBeGreaterThan(0.65); } } }); it("produces the same output in the worklet", () => { const worklet = loadWorklet(44100, 16000); const resample = createResampler(44100 / 16000); const expected: number[] = []; for (let block = 0; block < 32; block++) { const input = ramp(block * 128, 128, 1 / 4096); worklet.process(input); expected.push(...resample(input)); } const out = worklet.posted.flatMap((data) => [...data]); expect(expected).toHaveLength(1486); expect(out.length).toBeGreaterThan(1486 - 320); expect(out.length).toBeLessThanOrEqual(1486); expect(out).toEqual(expected.slice(0, out.length)); }); it("posts the worklet output in 20 ms chunks", () => { const worklet = loadWorklet(48000, 16000); for (let block = 0; block < 16; block++) worklet.process(ramp(0, 128)); expect(worklet.posted).toHaveLength(2); for (const data of worklet.posted) expect(data.length).toBeGreaterThanOrEqual(320); }); it("downmixes every input channel in the worklet", () => { const worklet = loadWorklet(16000, 16000); for (let block = 0; block < 3; block++) worklet.process(new Float32Array(128), new Float32Array(128).fill(0.5)); const out = worklet.posted.flatMap((data) => [...data]); expect(out.length).toBeGreaterThan(0); for (const value of out) expect(value).toBeCloseTo(0.25, 6); }); }); describe("microphone", () => { afterEach(() => { vi.unstubAllGlobals(); FakeContext.instances = []; FakeContext.workletSupported = true; FakeContext.addModule = async () => {}; FakeWorklet.instances = []; }); it("requests the microphone and opens a context at the device rate", async () => { const browser = installBrowser(); await openMicrophone({ sampleRate: 16000, onChunk: () => {} }); expect(browser.getUserMedia).toHaveBeenCalledWith({ audio: true }); expect(FakeContext.instances[0]?.sampleRate).toBe(48000); }); it("resumes a suspended capture context", async () => { installBrowser(); vi.stubGlobal( "AudioContext", class extends FakeContext { override state = "suspended"; }, ); await openMicrophone({ sampleRate: 16000, onChunk: () => {} }); expect(FakeContext.instances[0]?.resume).toHaveBeenCalled(); }); it("passes the target rate to the worklet", async () => { installBrowser(); await openMicrophone({ sampleRate: 16000, onChunk: () => {} }); expect(FakeWorklet.instances[0]?.options).toEqual({ processorOptions: { targetRate: 16000 }, }); }); it("loads the worklet from a blob url and revokes it", async () => { const browser = installBrowser(); await openMicrophone({ sampleRate: 16000, onChunk: () => {} }); expect( FakeContext.instances[0]?.audioWorklet?.addModule, ).toHaveBeenCalledWith("blob:worklet"); expect(browser.revokeObjectURL).toHaveBeenCalledWith("blob:worklet"); }); it("forwards worklet chunks as base64", async () => { installBrowser(); const onChunk = vi.fn(); await openMicrophone({ sampleRate: 16000, onChunk }); const samples = Float32Array.from([0.5, -0.5]); FakeWorklet.instances[0]?.port.onmessage?.({ data: samples }); expect(onChunk).toHaveBeenCalledWith(encodePcm16(samples)); }); it("falls back to a script processor without worklet support", async () => { FakeContext.workletSupported = false; installBrowser(); const onChunk = vi.fn(); await openMicrophone({ sampleRate: 16000, onChunk }); const context = FakeContext.instances[0]!; expect(context.createScriptProcessor).toHaveBeenCalledWith(1024, 1, 1); expect(context.mediaSource.connect).toHaveBeenCalledWith(context.analyser); expect(context.analyser.connect).toHaveBeenCalledWith(context.processor); expect(context.processor.connect).toHaveBeenCalledWith(context.destination); const samples = Float32Array.from([0, 0.3, 0.6, 0.9, 1.2, 1.5]); context.processor.onaudioprocess?.({ inputBuffer: { getChannelData: () => samples }, }); expect(decodePcm16(onChunk.mock.calls[0]![0] as string)).toHaveLength(2); expect(FakeWorklet.instances).toHaveLength(0); }); it.each([ [48000, 3, 683], [44100, 2.75625, 743], ])( "keeps fallback chunks below 25 ms at %i Hz without losing samples", async (rate, ratio, count) => { FakeContext.workletSupported = false; installBrowser(); vi.stubGlobal( "AudioContext", class extends FakeContext { constructor() { super({ sampleRate: rate }); } }, ); const onChunk = vi.fn(); await openMicrophone({ sampleRate: 16000, onChunk }); const context = FakeContext.instances[0]!; const size = context.createScriptProcessor.mock.calls[0]![0]; expect(size / rate).toBeLessThan(0.025); const inputs = [0, size].map((start) => ramp(start, size, 1 / 8192)); const resample = createResampler(ratio); const expected = inputs.flatMap((input) => [...resample(input)]); for (const input of inputs) context.processor.onaudioprocess?.({ inputBuffer: { getChannelData: () => input }, }); const out = onChunk.mock.calls.flatMap(([base64]) => [ ...decodePcm16(base64 as string), ]); expect(out).toHaveLength(count); for (const [k, value] of out.entries()) expect(value).toBeCloseTo(expected[k]!, 4); }, ); it("captures with the fallback when the worklet module is blocked", async () => { FakeContext.addModule = async () => { throw new DOMException("Blocked by CSP", "SecurityError"); }; const browser = installBrowser(); const onChunk = vi.fn(); const microphone = await openMicrophone({ sampleRate: 16000, onChunk }); const context = FakeContext.instances[0]!; const handler = context.processor.onaudioprocess!; const samples = Float32Array.from([0, 0, 0, 0.5, 0, 0]); const event = { inputBuffer: { getChannelData: () => samples, }, }; handler(event); expect(onChunk).toHaveBeenCalledWith( encodePcm16(createResampler(3)(samples)), ); expect(browser.track.stop).not.toHaveBeenCalled(); expect(browser.revokeObjectURL).toHaveBeenCalledWith("blob:worklet"); expect(FakeWorklet.instances).toHaveLength(0); microphone.setMuted(true); handler(event); expect(onChunk).toHaveBeenCalledTimes(1); microphone.setMuted(false); await microphone.close(); handler(event); expect(onChunk).toHaveBeenCalledTimes(1); expect(context.processor.onaudioprocess).toBeNull(); expect(browser.track.stop).toHaveBeenCalled(); expect(context.close).toHaveBeenCalled(); }); it("stops the tracks and closes the context when both capture paths fail", async () => { FakeContext.addModule = async () => { throw new Error("bad module"); }; const browser = installBrowser(); vi.stubGlobal( "AudioContext", class extends FakeContext { override createScriptProcessor = vi.fn(() => { throw new Error("processor unavailable"); }); }, ); await expect( openMicrophone({ sampleRate: 16000, onChunk: () => {} }), ).rejects.toThrow("processor unavailable"); expect(browser.track.stop).toHaveBeenCalled(); expect(FakeContext.instances[0]?.close).toHaveBeenCalled(); expect(browser.revokeObjectURL).toHaveBeenCalledWith("blob:worklet"); expect(FakeWorklet.instances).toHaveLength(0); }); it.each([false, true])( "cleans up an aborted setup when the module rejects: %s", async (rejectModule) => { let finishModule!: () => void; FakeContext.addModule = () => new Promise<void>((resolve, reject) => { finishModule = rejectModule ? () => reject(new Error("blocked module")) : resolve; }); const browser = installBrowser(); const controller = new AbortController(); const opening = openMicrophone({ sampleRate: 16000, onChunk: () => {}, signal: controller.signal, }); await vi.waitFor(() => expect( FakeContext.instances[0]?.audioWorklet?.addModule, ).toHaveBeenCalled(), ); controller.abort(new Error("cancelled")); finishModule(); await expect(opening).rejects.toThrow("cancelled"); expect(browser.track.stop).toHaveBeenCalled(); expect(FakeContext.instances[0]?.close).toHaveBeenCalled(); expect( FakeContext.instances[0]?.createScriptProcessor, ).not.toHaveBeenCalled(); expect(FakeWorklet.instances).toHaveLength(0); }, ); it("mutes by disabling the track and dropping chunks", async () => { const browser = installBrowser(); const onChunk = vi.fn(); const microphone = await openMicrophone({ sampleRate: 16000, onChunk }); const samples = Float32Array.from([0.5]); microphone.setMuted(true); expect(browser.track.enabled).toBe(false); FakeWorklet.instances[0]?.port.onmessage?.({ data: samples }); expect(onChunk).not.toHaveBeenCalled(); microphone.setMuted(false); expect(browser.track.enabled).toBe(true); FakeWorklet.instances[0]?.port.onmessage?.({ data: samples }); expect(onChunk).toHaveBeenCalledTimes(1); }); it("drops worklet messages that arrive after close", async () => { installBrowser(); const onChunk = vi.fn(); const microphone = await openMicrophone({ sampleRate: 16000, onChunk }); const worklet = FakeWorklet.instances[0]!; const handler = worklet.port.onmessage; await microphone.close(); expect(worklet.port.onmessage).toBeNull(); handler?.({ data: Float32Array.from([0.5]) }); expect(onChunk).not.toHaveBeenCalled(); }); it("closes the graph, the tracks, and the context", async () => { const browser = installBrowser(); const microphone = await openMicrophone({ sampleRate: 16000, onChunk: () => {}, }); await microphone.close(); const context = FakeContext.instances[0]!; expect(FakeWorklet.instances[0]?.disconnect).toHaveBeenCalled(); expect(context.mediaSource.disconnect).toHaveBeenCalled(); expect(browser.track.stop).toHaveBeenCalled(); expect(context.close).toHaveBeenCalled(); }); it("rejects without requesting permission when already aborted", async () => { const browser = installBrowser(); const controller = new AbortController(); controller.abort(new Error("cancelled")); await expect( openMicrophone({ sampleRate: 16000, onChunk: () => {}, signal: controller.signal, }), ).rejects.toThrow("cancelled"); expect(browser.getUserMedia).not.toHaveBeenCalled(); expect(FakeContext.instances).toHaveLength(0); }); it.each(["granted", "denied"])( "cancels a pending permission request before it is %s", async (permission) => { const browser = installBrowser(); let grant!: ( stream: Awaited<ReturnType<typeof browser.getUserMedia>>, ) => void; let deny!: (reason: unknown) => void; browser.getUserMedia.mockReturnValue( new Promise((resolve, reject) => { grant = resolve; deny = reject; }), ); const controller = new AbortController(); const onError = vi.fn(); const opening = openMicrophone({ sampleRate: 16000, onChunk: () => {}, signal: controller.signal, }).catch(onError); const reason = new Error("cancelled"); controller.abort(reason); try { await new Promise<void>((resolve) => setImmediate(resolve)); expect(onError).toHaveBeenCalledWith(reason); expect(FakeContext.instances).toHaveLength(0); } finally { if (permission === "granted") grant({ getTracks: () => [browser.track], getAudioTracks: () => [browser.track], }); else deny(new DOMException("Permission denied", "NotAllowedError")); await opening; await new Promise<void>((resolve) => setImmediate(resolve)); } expect(onError).toHaveBeenCalledTimes(1); expect(browser.track.stop).toHaveBeenCalledTimes( permission === "granted" ? 1 : 0, ); expect(FakeContext.instances).toHaveLength(0); }, ); it("shares pending microphone cleanup across repeated close calls", async () => { const browser = installBrowser(); const microphone = await openMicrophone({ sampleRate: 16000, onChunk: () => {}, }); const context = FakeContext.instances[0]!; let finishClose!: () => void; context.close.mockImplementation(() => { if (context.state === "closed") return Promise.reject( new DOMException("Already closed", "InvalidStateError"), ); context.state = "closed"; return new Promise<void>((resolve) => { finishClose = resolve; }); }); const settled = vi.fn(); const first = microphone.close().then(settled); const second = microphone.close().then(settled); const both = Promise.allSettled([first, second]); await Promise.resolve(); expect(settled).not.toHaveBeenCalled(); finishClose(); expect(await both).toEqual([ { status: "fulfilled", value: undefined }, { status: "fulfilled", value: undefined }, ]); await expect(microphone.close()).resolves.toBeUndefined(); expect(context.close).toHaveBeenCalledTimes(1); expect(browser.track.stop).toHaveBeenCalledTimes(1); }); it("releases a stream when cancellation wins just after permission resolves", async () => { const browser = installBrowser(); let grant!: ( stream: Awaited<ReturnType<typeof browser.getUserMedia>>, ) => void; browser.getUserMedia.mockReturnValue( new Promise((resolve) => { grant = resolve; }), ); const controller = new AbortController(); const opening = openMicrophone({ sampleRate: 16000, onChunk: () => {}, signal: controller.signal, }); const reason = new Error("cancelled"); grant({ getTracks: () => [browser.track], getAudioTracks: () => [browser.track], }); queueMicrotask(() => controller.abort(reason)); await expect(opening).rejects.toBe(reason); expect(browser.track.stop).toHaveBeenCalledTimes(1); }); it.each(["resume", "worklet"])( "releases the microphone without waiting for pending %s", async (stage) => { const browser = installBrowser(); let finish!: () => void; const pending = new Promise<void>((resolve) => { finish = resolve; }); if (stage === "worklet") FakeContext.addModule = () => pending; else vi.stubGlobal( "AudioContext", class extends FakeContext { override state = "suspended"; override resume = () => pending; }, ); const controller = new AbortController(); const onError = vi.fn(); const opening = openMicrophone({ sampleRate: 16000, onChunk: () => {}, signal: controller.signal, }).catch(onError); await new Promise<void>((resolve) => setImmediate(resolve)); const context = FakeContext.instances[0]!; const reason = new Error("cancelled"); controller.abort(reason); try { await new Promise<void>((resolve) => setImmediate(resolve)); expect(onError).toHaveBeenCalledWith(reason); expect(browser.track.stop).toHaveBeenCalledTimes(1); expect(context.state).toBe("closed"); } finally { finish(); await opening; await new Promise<void>((resolve) => setImmediate(resolve)); } expect(FakeWorklet.instances).toHaveLength(0); expect(context.createScriptProcessor).not.toHaveBeenCalled(); expect(context.close).toHaveBeenCalledTimes(1); if (stage === "worklet") expect(browser.revokeObjectURL).toHaveBeenCalledWith("blob:worklet"); }, ); }); describe("playback", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); FakeContext.instances = []; }); const setup = (onIdle?: () => void) => { vi.stubGlobal("AudioContext", FakeContext); const player = createPcmPlayer( onIdle ? { sampleRate: 24000, onIdle } : { sampleRate: 24000 }, ); return { player, context: FakeContext.instances[0]! }; }; it("copies the decoded samples into the buffer", () => { const { player, context } = setup(); const samples = Float32Array.from([0.5, -0.25]); player.enqueue(encodePcm16(samples)); const buffer = context.createBuffer.mock.results[0]!.value; expect(context.createBuffer).toHaveBeenCalledWith(1, 2, 24000); const copied = buffer.copyToChannel.mock.calls[0]![0] as Float32Array; expect(copied[0]).toBeCloseTo(0.5, 3); expect(copied[1]).toBeCloseTo(-0.25, 3); expect(buffer.copyToChannel).toHaveBeenCalledWith(copied, 0); }); it("schedules chunks back to back from the current time", () => { const { player, context } = setup(); context.currentTime = 1; player.enqueue(chunk(2400)); player.enqueue(chunk(2400)); expect(context.sources[0]?.start).toHaveBeenCalledWith(1); expect(context.sources[1]?.start).toHaveBeenCalledWith(1.1); }); it("never schedules in the past", () => { const { player, context } = setup(); player.enqueue(chunk(2400)); context.currentTime = 5; player.enqueue(chunk(2400)); expect(context.sources[1]?.start).toHaveBeenCalledWith(5); }); it("reports playing until the last source ends", () => { const { player, context } = setup(); player.enqueue(chunk(2400)); expect(player.isPlaying()).toBe(true); context.sources[0]?.onended?.(); expect(player.isPlaying()).toBe(false); }); it("calls onIdle once after the queue drains", () => { vi.useFakeTimers(); const onIdle = vi.fn(); const { player, context } = setup(onIdle); player.enqueue(chunk(2400)); player.enqueue(chunk(2400)); context.sources[0]?.onended?.(); vi.advanceTimersByTime(200); expect(onIdle).not.toHaveBeenCalled(); context.sources[1]?.onended?.(); vi.advanceTimersByTime(200); expect(onIdle).toHaveBeenCalledTimes(1); }); it("does not report idle across a short gap between chunks", () => { vi.useFakeTimers(); const onIdle = vi.fn(); const { player, context } = setup(onIdle); player.enqueue(chunk(2400)); context.sources[0]?.onended?.(); vi.advanceTimersByTime(50); player.enqueue(chunk(2400)); vi.advanceTimersByTime(200); expect(onIdle).not.toHaveBeenCalled(); }); it("clear stops every source and resets the cursor", () => { vi.useFakeTimers(); const onIdle = vi.fn(); const { player, context } = setup(onIdle); player.enqueue(chunk(2400)); player.enqueue(chunk(2400)); player.clear(); expect(context.sources[0]?.stop).toHaveBeenCalled(); expect(context.sources[1]?.stop).toHaveBeenCalled(); expect(player.isPlaying()).toBe(false); vi.advanceTimersByTime(200); expect(onIdle).not.toHaveBeenCalled(); context.currentTime = 0.05; player.enqueue(chunk(2400)); expect(context.sources[2]?.start).toHaveBeenCalledWith(0.05); }); it("skips empty chunks", () => { const { player, context } = setup(); player.enqueue(""); expect(context.createBufferSource).not.toHaveBeenCalled(); expect(player.isPlaying()).toBe(false); }); it("resumes a suspended context on enqueue", () => { const { player, context } = setup(); context.state = "suspended"; player.enqueue(chunk(2400)); expect(context.resume).toHaveBeenCalled(); }); it("close stops sources and closes the context", async () => { const { player, context } = setup(); player.enqueue(chunk(2400)); await player.close(); expect(context.sources[0]?.stop).toHaveBeenCalled(); expect(context.close).toHaveBeenCalled(); }); it("shares pending playback cleanup across repeated close calls", async () => { const { player, context } = setup(); player.enqueue(chunk(2400)); let finishClose!: () => void; context.close.mockImplementation(() => { if (context.state === "closed") return Promise.reject( new DOMException("Already closed", "InvalidStateError"), ); context.state = "closed"; return new Promise<void>((resolve) => { finishClose = resolve; }); }); const settled = vi.fn(); const first = player.close().then(settled); const second = player.close().then(settled); const both = Promise.allSettled([first, second]); await Promise.resolve(); expect(settled).not.toHaveBeenCalled(); finishClose(); expect(await both).toEqual([ { status: "fulfilled", value: undefined }, { status: "fulfilled", value: undefined }, ]); await expect(player.close()).resolves.toBeUndefined(); expect(context.close).toHaveBeenCalledTimes(1); expect(context.sources[0]?.stop).toHaveBeenCalledTimes(1); }); it("handles a pending resume rejected when playback closes", async () => { vi.useFakeTimers(); const onIdle = vi.fn(); const { player, context } = setup(onIdle); let rejectResume!: (reason: unknown) => void; const resume = new Promise<void>((_resolve, reject) => { rejectResume = reject; }); context.state = "suspended"; context.resume = () => resume; context.close.mockImplementation(async () => { context.state = "closed"; rejectResume( new DOMException("AudioContext closed", "InvalidStateError"), ); }); player.enqueue(chunk(2400)); await player.close(); await vi.advanceTimersByTimeAsync(1000); expect(context.state).toBe("closed"); expect(player.isPlaying()).toBe(false); expect(onIdle).not.toHaveBeenCalled(); }); it("does not report idle for a source that ends after close", async () => { vi.useFakeTimers(); const onIdle = vi.fn(); const { player, context } = setup(onIdle); player.enqueue(chunk(2400)); await player.close(); expect(context.sources[0]?.onended).toBeNull(); await vi.advanceTimersByTimeAsync(1000); expect(onIdle).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); }); });