| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- 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> = {}): 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<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();
- });
- });
|