import { describe, expect, it } 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 }); 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 { const now = { currentTime: 1.25 }; const node = { type: "", frequency: { setValueAtTime(): void {}, linearRampToValueAtTime(): void {} }, gain: { setValueAtTime(): void {}, linearRampToValueAtTime(): void {} }, connect(): void {}, start(): void {}, stop(): void {}, }; return { currentTime: 1.25, state: "running", destination: {}, createOscillator: () => node, createGain: () => ({ gain: node.gain, connect: node.connect }), decodeAudioData: async () => { throw new Error("cannot decode fake ogg"); }, resume: async () => undefined, close: async () => undefined, } as unknown as AudioContext; } 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(); }); });