sound.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { describe, expect, it } 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. describe("soundToAudioParams (pure def → Web Audio mapping)", () => {
  6. it("maps a synth def to oscillator parameters", () => {
  7. expect(soundToAudioParams(def())).toEqual({
  8. wave: "square",
  9. freq: 110,
  10. durationMs: 90,
  11. gain: 0.4,
  12. });
  13. });
  14. it("defaults gain when absent", () => {
  15. const params = soundToAudioParams(def({ synth: { wave: "sine", freq: 520, durationMs: 160 } }));
  16. expect(params?.gain).toBe(0.3);
  17. });
  18. it("carries slideTo when present and omits it when absent", () => {
  19. expect(soundToAudioParams(def({ synth: { wave: "sine", freq: 880, durationMs: 120, slideTo: 1320 } }))?.slideTo).toBe(1320);
  20. expect(soundToAudioParams(def())?.slideTo).toBeUndefined();
  21. });
  22. it("returns undefined for a def with only a reserved src (no synth)", () => {
  23. expect(soundToAudioParams({ id: "voice", src: "reserved.ogg" })).toBeUndefined();
  24. });
  25. });
  26. describe("soundSrcAssetId (pure src mapping, D31)", () => {
  27. it("returns the asset id for a def with src", () => {
  28. expect(soundSrcAssetId({ id: "till", src: "drop-1" })).toBe("drop-1");
  29. expect(soundSrcAssetId({ id: "both", synth: { wave: "sine", freq: 1, durationMs: 1 }, src: "click-1" })).toBe("click-1");
  30. });
  31. it("returns undefined for a synth-only def or a blank src", () => {
  32. expect(soundSrcAssetId(def())).toBeUndefined();
  33. expect(soundSrcAssetId({ id: "x", src: "" })).toBeUndefined();
  34. });
  35. });
  36. describe("createSoundPlayer (event wiring + lazy AudioContext)", () => {
  37. function fakeAudioContext(): AudioContext {
  38. const now = { currentTime: 1.25 };
  39. const node = {
  40. type: "",
  41. frequency: { setValueAtTime(): void {}, linearRampToValueAtTime(): void {} },
  42. gain: { setValueAtTime(): void {}, linearRampToValueAtTime(): void {} },
  43. connect(): void {},
  44. start(): void {},
  45. stop(): void {},
  46. };
  47. return {
  48. currentTime: 1.25,
  49. state: "running",
  50. destination: {},
  51. createOscillator: () => node,
  52. createGain: () => ({ gain: node.gain, connect: node.connect }),
  53. decodeAudioData: async () => {
  54. throw new Error("cannot decode fake ogg");
  55. },
  56. resume: async () => undefined,
  57. close: async () => undefined,
  58. } as unknown as AudioContext;
  59. }
  60. it("ignores non-sound-play events and unknown sound ids", () => {
  61. const player = createSoundPlayer(fakeAudioContext);
  62. player.setCatalog([def()]);
  63. player.onEvent({ event: "player-intent", payload: { soundId: "till" } });
  64. player.onEvent({ event: "sound-play", payload: { soundId: "nope" } });
  65. player.destroy();
  66. });
  67. it("creates the AudioContext lazily only on play", () => {
  68. let created = 0;
  69. const player = createSoundPlayer(() => {
  70. created += 1;
  71. return fakeAudioContext();
  72. });
  73. expect(created).toBe(0);
  74. player.setCatalog([def()]);
  75. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  76. expect(created).toBe(1);
  77. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  78. expect(created).toBe(1);
  79. player.destroy();
  80. });
  81. it("synthesizes the resolved def with ramp parameters on a sound-play frame", () => {
  82. const createdNodes: { freq: number; slideTo?: number; durationMs: number; gain: number }[] = [];
  83. const context = fakeAudioContext();
  84. const ctx = context as unknown as {
  85. createOscillator(): { type: string; frequency: { setValueAtTime(v: number, t: number): void; linearRampToValueAtTime(v: number, t: number): void }; connect(): void; start(): void; stop(): void };
  86. };
  87. const orig = context.createOscillator;
  88. ctx.createOscillator = () => {
  89. 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 };
  90. osc.frequency.setValueAtTime = (v: number) => {
  91. createdNodes[createdNodes.length] = { freq: v, durationMs: 120, gain: 0.5 };
  92. };
  93. osc.frequency.linearRampToValueAtTime = (v: number) => {
  94. const last = createdNodes[createdNodes.length - 1];
  95. if (last) last.slideTo = v;
  96. };
  97. return osc;
  98. };
  99. const player = createSoundPlayer(() => context);
  100. player.setCatalog([def({ synth: { wave: "sine", freq: 880, durationMs: 120, gain: 0.5, slideTo: 1320 } })]);
  101. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  102. expect(createdNodes[0]).toEqual({ freq: 880, durationMs: 120, gain: 0.5, slideTo: 1320 });
  103. player.destroy();
  104. });
  105. it("prime() creates the context on a user gesture without playing", () => {
  106. let created = 0;
  107. const player = createSoundPlayer(() => {
  108. created += 1;
  109. return fakeAudioContext();
  110. });
  111. player.prime();
  112. expect(created).toBe(1);
  113. player.destroy();
  114. });
  115. it("silently skips a src def whose audio asset is missing from the table", () => {
  116. let created = 0;
  117. const player = createSoundPlayer(() => {
  118. created += 1;
  119. return fakeAudioContext();
  120. });
  121. player.setCatalog([{ id: "till", src: "drop-1" }]);
  122. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  123. expect(created).toBe(0);
  124. player.destroy();
  125. });
  126. it("silently skips a src def whose asset kind is not audio", () => {
  127. const assets: Record<string, AssetEntry> = { "drop-1": { kind: "image", mime: "image/png", data: "x" } };
  128. let created = 0;
  129. const player = createSoundPlayer(() => {
  130. created += 1;
  131. return fakeAudioContext();
  132. });
  133. player.setCatalog([{ id: "till", src: "drop-1" }]);
  134. player.setAssets(assets);
  135. player.onEvent({ event: "sound-play", payload: { soundId: "till" } });
  136. expect(created).toBe(0);
  137. player.destroy();
  138. });
  139. it("src takes precedence over synth at play time", () => {
  140. const assets: Record<string, AssetEntry> = { "click-1": { kind: "audio", mime: "audio/ogg", data: "T2dnUw==" } };
  141. const createdNodes: { freq?: number }[] = [];
  142. const context = fakeAudioContext();
  143. const ctx = context as unknown as {
  144. createOscillator(): { type: string; frequency: { setValueAtTime(v: number): void }; connect(): void; start(): void; stop(): void };
  145. };
  146. const orig = context.createOscillator;
  147. ctx.createOscillator = () => {
  148. const osc = orig.call(context) as { type: string; frequency: { setValueAtTime(v: number): void }; connect(): void; start(): void; stop(): void };
  149. osc.frequency.setValueAtTime = (v: number) => {
  150. createdNodes[createdNodes.length] = { freq: v };
  151. };
  152. return osc;
  153. };
  154. const player = createSoundPlayer(() => context);
  155. player.setCatalog([
  156. { id: "both", synth: { wave: "sine", freq: 880, durationMs: 100 }, src: "click-1" },
  157. ]);
  158. player.setAssets(assets);
  159. player.onEvent({ event: "sound-play", payload: { soundId: "both" } });
  160. // src path: decode fails (fake OGG bytes) → silently skipped, no oscillator
  161. expect(createdNodes).toHaveLength(0);
  162. player.destroy();
  163. });
  164. });