sound.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import type { AssetEntry, SoundDef } from "@nengine/core";
  3. import { createSoundPlayer, soundSrcAssetId, soundToAudioParams } from "../src/sound.js";
  4. const def = (overrides: Partial<SoundDef> = {}): SoundDef => ({ id: "till", synth: { wave: "square", freq: 110, durationMs: 90, gain: 0.4 }, ...overrides });
  5. beforeEach(() => {
  6. vi.useFakeTimers();
  7. });
  8. describe("soundToAudioParams (pure def → Web Audio mapping)", () => {
  9. it("maps a synth def to oscillator parameters", () => {
  10. expect(soundToAudioParams(def())).toEqual({
  11. wave: "square",
  12. freq: 110,
  13. durationMs: 90,
  14. gain: 0.4,
  15. });
  16. });
  17. it("defaults gain when absent", () => {
  18. const params = soundToAudioParams(def({ synth: { wave: "sine", freq: 520, durationMs: 160 } }));
  19. expect(params?.gain).toBe(0.3);
  20. });
  21. it("carries slideTo when present and omits it when absent", () => {
  22. expect(soundToAudioParams(def({ synth: { wave: "sine", freq: 880, durationMs: 120, slideTo: 1320 } }))?.slideTo).toBe(1320);
  23. expect(soundToAudioParams(def())?.slideTo).toBeUndefined();
  24. });
  25. it("returns undefined for a def with only a reserved src (no synth)", () => {
  26. expect(soundToAudioParams({ id: "voice", src: "reserved.ogg" })).toBeUndefined();
  27. });
  28. });
  29. describe("soundSrcAssetId (pure src mapping, D31)", () => {
  30. it("returns the asset id for a def with src", () => {
  31. expect(soundSrcAssetId({ id: "till", src: "drop-1" })).toBe("drop-1");
  32. expect(soundSrcAssetId({ id: "both", synth: { wave: "sine", freq: 1, durationMs: 1 }, src: "click-1" })).toBe("click-1");
  33. });
  34. it("returns undefined for a synth-only def or a blank src", () => {
  35. expect(soundSrcAssetId(def())).toBeUndefined();
  36. expect(soundSrcAssetId({ id: "x", src: "" })).toBeUndefined();
  37. });
  38. });
  39. describe("createSoundPlayer (event wiring + lazy AudioContext)", () => {
  40. function fakeAudioContext(): AudioContext & {
  41. nodeCalls: { start: any[]; stop: any[]; disconnect: any[] };
  42. transitionLog: [string, number, number][];
  43. gainValue: number;
  44. } {
  45. const transitionLog: [string, number, number][] = [];
  46. let gainValue = 0;
  47. const nodeCalls = { start: [] as any[], stop: [] as any[], disconnect: [] as any[] };
  48. const createGainLike = () => {
  49. return {
  50. setValueAtTime(v: number, t: number): void {
  51. gainValue = v;
  52. transitionLog.push(["setValueAtTime", v, t]);
  53. },
  54. linearRampToValueAtTime(v: number, t: number): void {
  55. gainValue = v;
  56. transitionLog.push(["linearRampToValueAtTime", v, t]);
  57. },
  58. get value(): number {
  59. return gainValue;
  60. },
  61. set value(v: number) {
  62. gainValue = v;
  63. },
  64. };
  65. };
  66. const sharedGain = createGainLike();
  67. const createNode = () => {
  68. const freqGain = createGainLike();
  69. return {
  70. type: "",
  71. frequency: {
  72. setValueAtTime(v: number, t: number): void {
  73. transitionLog.push(["setValueAtTime", v, t]);
  74. },
  75. linearRampToValueAtTime(v: number, t: number): void {
  76. transitionLog.push(["linearRampToValueAtTime", v, t]);
  77. },
  78. },
  79. gain: sharedGain,
  80. connect(): void {},
  81. disconnect(): void {
  82. nodeCalls.disconnect.push({});
  83. },
  84. start(...args: any[]): void {
  85. nodeCalls.start.push(args);
  86. },
  87. stop(...args: any[]): void {
  88. nodeCalls.stop.push(args);
  89. },
  90. };
  91. };
  92. const mainNode = createNode();
  93. return {
  94. currentTime: 1.25,
  95. state: "running",
  96. destination: {},
  97. createOscillator: () => createNode(),
  98. createGain: () => ({ gain: createGainLike(), connect(): void {} }),
  99. decodeAudioData: async () => {
  100. throw new Error("cannot decode fake ogg");
  101. },
  102. resume: async () => undefined,
  103. close: async () => undefined,
  104. nodeCalls,
  105. transitionLog,
  106. gainValue,
  107. } as unknown as AudioContext & {
  108. nodeCalls: { start: any[]; stop: any[]; disconnect: any[] };
  109. transitionLog: [string, number, number][];
  110. gainValue: number;
  111. };
  112. }
  113. it("ignores non-sound-play events and unknown sound ids", () => {
  114. const player = createSoundPlayer(fakeAudioContext);
  115. player.setCatalog([def()]);
  116. player.onEvent({ event: "player-intent", payload: { soundId: "till" } });
  117. player.onEvent({ event: "sound-play", payload: { soundId: "nope" } });
  118. player.destroy();
  119. });
  120. it("creates the AudioContext lazily only on play", () => {
  121. let created = 0;
  122. const player = createSoundPlayer(() => {
  123. created += 1;
  124. return fakeAudioContext();
  125. });
  126. expect(created).toBe(0);
  127. player.setCatalog([def()]);
  128. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  129. expect(created).toBe(1);
  130. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  131. expect(created).toBe(1);
  132. player.destroy();
  133. });
  134. it("synthesizes the resolved def with ramp parameters on a sound-play frame", () => {
  135. const createdNodes: { freq: number; slideTo?: number; durationMs: number; gain: number }[] = [];
  136. const context = fakeAudioContext();
  137. const ctx = context as unknown as {
  138. createOscillator(): { type: string; frequency: { setValueAtTime(v: number, t: number): void; linearRampToValueAtTime(v: number, t: number): void }; connect(): void; start(): void; stop(): void };
  139. };
  140. const orig = context.createOscillator;
  141. ctx.createOscillator = () => {
  142. 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 };
  143. osc.frequency.setValueAtTime = (v: number) => {
  144. createdNodes[createdNodes.length] = { freq: v, durationMs: 120, gain: 0.5 };
  145. };
  146. osc.frequency.linearRampToValueAtTime = (v: number) => {
  147. const last = createdNodes[createdNodes.length - 1];
  148. if (last) last.slideTo = v;
  149. };
  150. return osc;
  151. };
  152. const player = createSoundPlayer(() => context);
  153. player.setCatalog([def({ synth: { wave: "sine", freq: 880, durationMs: 120, gain: 0.5, slideTo: 1320 } })]);
  154. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  155. expect(createdNodes[0]).toEqual({ freq: 880, durationMs: 120, gain: 0.5, slideTo: 1320 });
  156. player.destroy();
  157. });
  158. it("prime() creates the context on a user gesture without playing", () => {
  159. let created = 0;
  160. const player = createSoundPlayer(() => {
  161. created += 1;
  162. return fakeAudioContext();
  163. });
  164. player.prime();
  165. expect(created).toBe(1);
  166. player.destroy();
  167. });
  168. it("silently skips a src def whose audio asset is missing from the table", () => {
  169. let created = 0;
  170. const player = createSoundPlayer(() => {
  171. created += 1;
  172. return fakeAudioContext();
  173. });
  174. player.setCatalog([{ id: "till", src: "drop-1" }]);
  175. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  176. expect(created).toBe(0);
  177. player.destroy();
  178. });
  179. it("silently skips a src def whose asset kind is not audio", () => {
  180. const assets: Record<string, AssetEntry> = { "drop-1": { kind: "image", mime: "image/png", data: "x" } };
  181. let created = 0;
  182. const player = createSoundPlayer(() => {
  183. created += 1;
  184. return fakeAudioContext();
  185. });
  186. player.setCatalog([{ id: "till", src: "drop-1" }]);
  187. player.setAssets(assets);
  188. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  189. expect(created).toBe(0);
  190. player.destroy();
  191. });
  192. it("src takes precedence over synth at play time", () => {
  193. const assets: Record<string, AssetEntry> = { "click-1": { kind: "audio", mime: "audio/ogg", data: "T2dnUw==" } };
  194. const createdNodes: { freq?: number }[] = [];
  195. const context = fakeAudioContext();
  196. const ctx = context as unknown as {
  197. createOscillator(): { type: string; frequency: { setValueAtTime(v: number): void }; connect(): void; start(): void; stop(): void };
  198. };
  199. const orig = context.createOscillator;
  200. ctx.createOscillator = () => {
  201. const osc = orig.call(context) as { type: string; frequency: { setValueAtTime(v: number): void }; connect(): void; start(): void; stop(): void };
  202. osc.frequency.setValueAtTime = (v: number) => {
  203. createdNodes[createdNodes.length] = { freq: v };
  204. };
  205. return osc;
  206. };
  207. const player = createSoundPlayer(() => context);
  208. player.setCatalog([
  209. { id: "both", synth: { wave: "sine", freq: 880, durationMs: 100 }, src: "click-1" },
  210. ]);
  211. player.setAssets(assets);
  212. player.onEvent({ event: "sound-play", payload: { soundId: "both" } });
  213. // src path: decode fails (fake OGG bytes) → silently skipped, no oscillator
  214. expect(createdNodes).toHaveLength(0);
  215. player.destroy();
  216. });
  217. it("TR-11.1: playBgm('A') → playBgm('B') → stopBgm(200): currentBgm transitions and fade transitions logged", () => {
  218. const context = fakeAudioContext();
  219. const player = createSoundPlayer(() => context);
  220. player.setCatalog([
  221. { id: "A", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } },
  222. { id: "B", synth: { wave: "triangle", freq: 660, durationMs: 500, gain: 0.5 } },
  223. ]);
  224. player.playBgm("A");
  225. expect(player.currentBgm()).toBe("A");
  226. player.playBgm("B");
  227. expect(player.currentBgm()).toBe("B");
  228. player.stopBgm(200);
  229. expect(player.currentBgm()).toBe("B");
  230. vi.advanceTimersByTime(300);
  231. expect(player.currentBgm()).toBeNull();
  232. const rampCount = context.transitionLog.filter(([op]) => op === "linearRampToValueAtTime").length;
  233. expect(rampCount).toBeGreaterThanOrEqual(2);
  234. player.destroy();
  235. });
  236. it("TR-11.2: setBgmVolume(0.3) applies gain value", () => {
  237. const context = fakeAudioContext();
  238. const player = createSoundPlayer(() => context);
  239. player.setCatalog([
  240. { id: "bgm1", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } },
  241. ]);
  242. player.playBgm("bgm1");
  243. player.setBgmVolume(0.3);
  244. const setValCalls = context.transitionLog.filter(
  245. ([op, v]) => op === "setValueAtTime" && Math.abs(v - 0.3) < 0.0001
  246. );
  247. expect(setValCalls.length).toBeGreaterThanOrEqual(1);
  248. player.destroy();
  249. });
  250. it("bgm-play and bgm-stop events dispatch to player methods", () => {
  251. const context = fakeAudioContext();
  252. const player = createSoundPlayer(() => context);
  253. player.setCatalog([
  254. { id: "track1", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } },
  255. ]);
  256. player.onEvent({ event: "bgm-play", payload: { soundId: "track1", fadeMs: 150 } });
  257. expect(player.currentBgm()).toBe("track1");
  258. player.onEvent({ event: "bgm-stop", payload: { fadeMs: 100 } });
  259. vi.advanceTimersByTime(200);
  260. expect(player.currentBgm()).toBeNull();
  261. player.destroy();
  262. });
  263. it("playBgm with a missing asset never claims the BGM slot and stopBgm clears cleanly", () => {
  264. const player = createSoundPlayer(fakeAudioContext);
  265. player.setCatalog([{ id: "ghost", src: "missing-asset" }]);
  266. player.setAssets({});
  267. player.playBgm("ghost");
  268. expect(player.currentBgm()).toBeNull(); // nothing started — no stale id
  269. player.stopBgm(100);
  270. expect(player.currentBgm()).toBeNull();
  271. player.destroy();
  272. });
  273. it("a src whose asset cannot be decoded stays silent and stops cleanly", async () => {
  274. const player = createSoundPlayer(fakeAudioContext); // fake decodeAudioData throws
  275. player.setCatalog([{ id: "track1", src: "res-1" }]);
  276. player.setAssets({ "res-1": { kind: "audio", mime: "audio/ogg", data: "bm90LW9nZzE=" } });
  277. player.playBgm("track1");
  278. await vi.runAllTimersAsync(); // flush the failing decode promise
  279. expect(player.currentBgm()).toBeNull();
  280. player.stopBgm(100);
  281. expect(player.currentBgm()).toBeNull();
  282. player.destroy();
  283. });
  284. it("a failed BGM does not prevent a later valid BGM from playing", () => {
  285. const player = createSoundPlayer(fakeAudioContext);
  286. player.setCatalog([
  287. { id: "ghost", src: "missing-asset" },
  288. { id: "real", synth: { wave: "sine", freq: 440, durationMs: 500, gain: 0.6 } },
  289. ]);
  290. player.playBgm("ghost");
  291. player.playBgm("real");
  292. expect(player.currentBgm()).toBe("real");
  293. player.destroy();
  294. });
  295. });