| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- 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> = {}): 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<string, AssetEntry> = { "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<string, AssetEntry> = { "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();
- });
- });
|