/** * The library between `Play sound` and the audio device. Everything here is about the gap between * asking for a sound and being able to play it: the bytes are fetched and decoded on first use, so * a stop can land while a play is still in flight. * * The device is stubbed – nothing makes a noise in a unit test, and Web Audio does not exist here. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SoundLibrary } from '../../src/viewport/SoundLibrary'; const FILE = 'a.wav'; const OTHER = 'b.wav'; interface FakeSource { buffer: unknown; loop: boolean; onended: (() => void) | null; started: boolean; stopped: boolean; connect: (target: unknown) => unknown; start: () => void; stop: () => void; } const sources: FakeSource[] = []; /** Resolves the pending decodes by hand, so a test decides when the bytes arrive. */ let decodes: (() => void)[] = []; function fakeContext() { return { state: 'running', destination: {}, resume: () => Promise.resolve(), suspend: () => Promise.resolve(), decodeAudioData: () => new Promise((resolve) => decodes.push(() => resolve({ duration: 1.5 }))), createBufferSource: () => { const source: FakeSource = { buffer: null, loop: false, onended: null, started: false, stopped: false, connect: (target: unknown) => target, start: () => { source.started = true; }, stop: () => { source.stopped = true; }, }; sources.push(source); return source; }, createGain: () => ({ gain: { value: 1 }, connect: (target: unknown) => target }), }; } /** Lets the promise chain inside `play` run to the point where it would start a voice. */ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { sources.length = 0; decodes = []; vi.stubGlobal( 'AudioContext', class { constructor() { return fakeContext(); } }, ); vi.stubGlobal('fetch', () => Promise.resolve({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }), ); }); afterEach(() => { vi.unstubAllGlobals(); }); /** Plays `file` and lets its bytes arrive, unless `hold` keeps the decode pending. */ async function play(library: SoundLibrary, file = FILE, { hold = false } = {}): Promise { library.play(file, 1, false); await settle(); if (hold) return; for (const resolve of decodes.splice(0)) resolve(); await settle(); } describe('the sound library', () => { it('fetches and decodes a file once, however often it is played', async () => { const library = new SoundLibrary(); await play(library); await play(library); expect(library.stats()).toMatchObject({ started: 2, decoded: 1, playing: 2 }); // Two voices of one file, both running: plays overlap rather than replacing each other. expect(sources.filter((s) => s.started)).toHaveLength(2); }); it('remembers what it last started, so a muted check can see it at all', async () => { const library = new SoundLibrary(); library.play(FILE, 0.25, true); await settle(); for (const resolve of decodes.splice(0)) resolve(); await settle(); expect(library.stats().last).toEqual({ file: FILE, volume: 0.25, loop: true }); }); it('a stop while the bytes are in flight keeps the sound from starting afterwards', async () => { const library = new SoundLibrary(); await play(library, FILE, { hold: true }); library.stop(FILE); for (const resolve of decodes.splice(0)) resolve(); await settle(); expect(library.stats()).toMatchObject({ started: 0, playing: 0 }); }); it('but a stop before a play does not: an earlier silence is not a standing veto', async () => { const library = new SoundLibrary(); // Exactly what ending a run does – and the next run has to be able to play that sound again, // even though its bytes are still unread at that point. library.stop(''); library.stop(FILE); await play(library); expect(library.stats()).toMatchObject({ started: 1, playing: 1 }); }); it('stopping everything silences every voice, and only the named file otherwise', async () => { const library = new SoundLibrary(); await play(library, FILE); await play(library, OTHER); library.stop(FILE); expect(library.stats()).toMatchObject({ playing: 1, stopped: 1 }); library.stopAll(); expect(library.stats()).toMatchObject({ playing: 0, stopped: 2 }); expect(sources.every((s) => s.stopped)).toBe(true); }); it('a voice that ends by itself leaves the count, without being stopped', async () => { const library = new SoundLibrary(); await play(library); sources[0]?.onended?.(); expect(library.stats()).toMatchObject({ playing: 0, started: 1, stopped: 0 }); }); it('a file that cannot be loaded fails once and is never asked for again', async () => { const library = new SoundLibrary(); vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 404 })); vi.spyOn(console, 'error').mockImplementation(() => {}); expect(await library.load(FILE)).toBeNull(); expect(await library.load(FILE)).toBeNull(); expect(library.stats()).toMatchObject({ failed: 1, started: 0 }); library.play(FILE, 1, false); await settle(); expect(library.stats().started).toBe(0); }); });