import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AssetEntry, SoundDef } from "@nengine/core"; import { createSoundPlayer, soundSrcAssetId, soundToAudioParams } from "../src/sound.js"; const def = (overrides: Partial = {}): SoundDef => ({ id: "till", synth: { wave: "square", freq: 110, durationMs: 90, gain: 0.4 }, ...overrides }); beforeEach(() => { vi.useFakeTimers(); }); describe("soundToAudioParams (pure def → Web Audio mapping)", () => { it("maps a synth def to oscillator parameters", () => { expect(soundToAudioParams(def())).toEqual({ wave: "square", freq: 110, durationMs: 90, gain: 0.4, }); }); it("defaults gain when absent", () => { const params = soundToAudioParams(def({ synth: { wave: "sine", freq: 520, durationMs: 160 } })); expect(params?.gain).toBe(0.3); }); it("carries slideTo when present and omits it when absent", () => { expect(soundToAudioParams(def({ synth: { wave: "sine", freq: 880, durationMs: 120, slideTo: 1320 } }))?.slideTo).toBe(1320); expect(soundToAudioParams(def())?.slideTo).toBeUndefined(); }); it("returns undefined for a def with only a reserved src (no synth)", () => { expect(soundToAudioParams({ id: "voice", src: "reserved.ogg" })).toBeUndefined(); }); }); describe("soundSrcAssetId (pure src mapping, D31)", () => { it("returns the asset id for a def with src", () => { expect(soundSrcAssetId({ id: "till", src: "drop-1" })).toBe("drop-1"); expect(soundSrcAssetId({ id: "both", synth: { wave: "sine", freq: 1, durationMs: 1 }, src: "click-1" })).toBe("click-1"); }); it("returns undefined for a synth-only def or a blank src", () => { expect(soundSrcAssetId(def())).toBeUndefined(); expect(soundSrcAssetId({ id: "x", src: "" })).toBeUndefined(); }); }); describe("createSoundPlayer (event wiring + lazy AudioContext)", () => { function fakeAudioContext(): AudioContext & { nodeCalls: { start: any[]; stop: any[]; disconnect: any[] }; transitionLog: [string, number, number][]; gainValue: number; } { const transitionLog: [string, number, number][] = []; let gainValue = 0; const nodeCalls = { start: [] as any[], stop: [] as any[], disconnect: [] as any[] }; const createGainLike = () => { return { setValueAtTime(v: number, t: number): void { gainValue = v; transitionLog.push(["setValueAtTime", v, t]); }, linearRampToValueAtTime(v: number, t: number): void { gainValue = v; transitionLog.push(["linearRampToValueAtTime", v, t]); }, get value(): number { return gainValue; }, set value(v: number) { gainValue = v; }, }; }; const sharedGain = createGainLike(); const createNode = () => { const freqGain = createGainLike(); return { type: "", frequency: { setValueAtTime(v: number, t: number): void { transitionLog.push(["setValueAtTime", v, t]); }, linearRampToValueAtTime(v: number, t: number): void { transitionLog.push(["linearRampToValueAtTime", v, t]); }, }, gain: sharedGain, connect(): void {}, disconnect(): void { nodeCalls.disconnect.push({}); }, start(...args: any[]): void { nodeCalls.start.push(args); }, stop(...args: any[]): void { nodeCalls.stop.push(args); }, }; }; const mainNode = createNode(); return { currentTime: 1.25, state: "running", destination: {}, createOscillator: () => createNode(), createGain: () => ({ gain: createGainLike(), connect(): void {} }), decodeAudioData: async () => { throw new Error("cannot decode fake ogg"); }, resume: async () => undefined, close: async () => undefined, nodeCalls, transitionLog, gainValue, } as unknown as AudioContext & { nodeCalls: { start: any[]; stop: any[]; disconnect: any[] }; transitionLog: [string, number, number][]; gainValue: number; }; } it("ignores non-sound-play events and unknown sound ids", () => { const player = createSoundPlayer(fakeAudioContext); player.setCatalog([def()]); player.onEvent({ event: "player-intent", payload: { soundId: "till" } }); player.onEvent({ event: "sound-play", payload: { soundId: "nope" } }); player.destroy(); }); it("creates the AudioContext lazily only on play", () => { let created = 0; const player = createSoundPlayer(() => { created += 1; return fakeAudioContext(); }); expect(created).toBe(0); player.setCatalog([def()]); player.onEvent({ event: "sound-play", payload: { soundId: "till" } }); expect(created).toBe(1); player.onEvent({ event: "sound-play", payload: { soundId: "till" } }); expect(created).toBe(1); player.destroy(); }); it("synthesizes the resolved def with ramp parameters on a sound-play frame", () => { const createdNodes: { freq: number; slideTo?: number; durationMs: number; gain: number }[] = []; const context = fakeAudioContext(); const ctx = context as unknown as { createOscillator(): { type: string; frequency: { setValueAtTime(v: number, t: number): void; linearRampToValueAtTime(v: number, t: number): void }; connect(): void; start(): void; stop(): void }; }; const orig = context.createOscillator; ctx.createOscillator = () => { const osc = orig.call(context) as { type: string; frequency: { setValueAtTime(v: number, t: number): void; linearRampToValueAtTime(v: number, t: number): void }; connect(): void; start(): void; stop(): void }; osc.frequency.setValueAtTime = (v: number) => { createdNodes[createdNodes.length] = { freq: v, durationMs: 120, gain: 0.5 }; }; osc.frequency.linearRampToValueAtTime = (v: number) => { const last = createdNodes[createdNodes.length - 1]; if (last) last.slideTo = v; }; return osc; }; const player = createSoundPlayer(() => context); player.setCatalog([def({ synth: { wave: "sine", freq: 880, durationMs: 120, gain: 0.5, slideTo: 1320 } })]); player.onEvent({ event: "sound-play", payload: { soundId: "till" } }); expect(createdNodes[0]).toEqual({ freq: 880, durationMs: 120, gain: 0.5, slideTo: 1320 }); player.destroy(); }); it("prime() creates the context on a user gesture without playing", () => { let created = 0; const player = createSoundPlayer(() => { created += 1; return fakeAudioContext(); }); player.prime(); expect(created).toBe(1); player.destroy(); }); it("silently skips a src def whose audio asset is missing from the table", () => { let created = 0; const player = createSoundPlayer(() => { created += 1; return fakeAudioContext(); }); player.setCatalog([{ id: "till", src: "drop-1" }]); player.onEvent({ event: "sound-play", payload: { soundId: "till" } }); expect(created).toBe(0); player.destroy(); }); it("silently skips a src def whose asset kind is not audio", () => { const assets: Record = { "drop-1": { kind: "image", mime: "image/png", data: "x" } }; let created = 0; const player = createSoundPlayer(() => { created += 1; return fakeAudioContext(); }); player.setCatalog([{ id: "till", src: "drop-1" }]); player.setAssets(assets); player.onEvent({ event: "sound-play", payload: { soundId: "till" } }); expect(created).toBe(0); player.destroy(); }); it("src takes precedence over synth at play time", () => { const assets: Record = { "click-1": { kind: "audio", mime: "audio/ogg", data: "T2dnUw==" } }; const createdNodes: { freq?: number }[] = []; const context = fakeAudioContext(); const ctx = context as unknown as { createOscillator(): { type: string; frequency: { setValueAtTime(v: number): void }; connect(): void; start(): void; stop(): void }; }; const orig = context.createOscillator; ctx.createOscillator = () => { const osc = orig.call(context) as { type: string; frequency: { setValueAtTime(v: number): void }; connect(): void; start(): void; stop(): void }; osc.frequency.setValueAtTime = (v: number) => { createdNodes[createdNodes.length] = { freq: v }; }; return osc; }; const player = createSoundPlayer(() => context); player.setCatalog([ { id: "both", synth: { wave: "sine", freq: 880, durationMs: 100 }, src: "click-1" }, ]); player.setAssets(assets); player.onEvent({ event: "sound-play", payload: { soundId: "both" } }); // src path: decode fails (fake OGG bytes) → silently skipped, no oscillator expect(createdNodes).toHaveLength(0); player.destroy(); }); it("TR-11.1: playBgm('A') → playBgm('B') → stopBgm(200): currentBgm transitions and fade transitions logged", () => { const context = fakeAudioContext(); const player = createSoundPlayer(() => context); player.setCatalog([ { id: "A", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } }, { id: "B", synth: { wave: "triangle", freq: 660, durationMs: 500, gain: 0.5 } }, ]); player.playBgm("A"); expect(player.currentBgm()).toBe("A"); player.playBgm("B"); expect(player.currentBgm()).toBe("B"); player.stopBgm(200); expect(player.currentBgm()).toBe("B"); vi.advanceTimersByTime(300); expect(player.currentBgm()).toBeNull(); const rampCount = context.transitionLog.filter(([op]) => op === "linearRampToValueAtTime").length; expect(rampCount).toBeGreaterThanOrEqual(2); player.destroy(); }); it("TR-11.2: setBgmVolume(0.3) applies gain value", () => { const context = fakeAudioContext(); const player = createSoundPlayer(() => context); player.setCatalog([ { id: "bgm1", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } }, ]); player.playBgm("bgm1"); player.setBgmVolume(0.3); const setValCalls = context.transitionLog.filter( ([op, v]) => op === "setValueAtTime" && Math.abs(v - 0.3) < 0.0001 ); expect(setValCalls.length).toBeGreaterThanOrEqual(1); player.destroy(); }); it("bgm-play and bgm-stop events dispatch to player methods", () => { const context = fakeAudioContext(); const player = createSoundPlayer(() => context); player.setCatalog([ { id: "track1", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } }, ]); player.onEvent({ event: "bgm-play", payload: { soundId: "track1", fadeMs: 150 } }); expect(player.currentBgm()).toBe("track1"); player.onEvent({ event: "bgm-stop", payload: { fadeMs: 100 } }); vi.advanceTimersByTime(200); expect(player.currentBgm()).toBeNull(); player.destroy(); }); it("playBgm with a missing asset never claims the BGM slot and stopBgm clears cleanly", () => { const player = createSoundPlayer(fakeAudioContext); player.setCatalog([{ id: "ghost", src: "missing-asset" }]); player.setAssets({}); player.playBgm("ghost"); expect(player.currentBgm()).toBeNull(); // nothing started — no stale id player.stopBgm(100); expect(player.currentBgm()).toBeNull(); player.destroy(); }); it("a src whose asset cannot be decoded stays silent and stops cleanly", async () => { const player = createSoundPlayer(fakeAudioContext); // fake decodeAudioData throws player.setCatalog([{ id: "track1", src: "res-1" }]); player.setAssets({ "res-1": { kind: "audio", mime: "audio/ogg", data: "bm90LW9nZzE=" } }); player.playBgm("track1"); await vi.runAllTimersAsync(); // flush the failing decode promise expect(player.currentBgm()).toBeNull(); player.stopBgm(100); expect(player.currentBgm()).toBeNull(); player.destroy(); }); it("a failed BGM does not prevent a later valid BGM from playing", () => { const player = createSoundPlayer(fakeAudioContext); player.setCatalog([ { id: "ghost", src: "missing-asset" }, { id: "real", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } }, ]); player.playBgm("ghost"); player.playBgm("real"); expect(player.currentBgm()).toBe("real"); player.destroy(); }); });