Split The code is public; the app answers only to the crew.

`audio.ts` has never been tested: a recording AudioContext, the way the renderer has a recording GL context #152

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/cs:scout/80-audio-ts-has-never-been-tested-a-recordi → main 1 commit fork

  • Merged. scout/80-audio-ts-has-never-been-tested-a-recordi was merged into main as c506958.

Review

Sign in to review this change.
3 files changed +378 −4
README.md +6 −2
@@ -445,13 +445,17 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
445445 `settings` (mute, volume, sensitivity and difficulty: read, validated, and
446446 never NaN or an unknown name),
447447 `renderer`, `hud`, `input`, `audio`, `main`.
448 Everything except the last five and `weights` is headless and tested;
448 Everything except the last four and `weights` is headless and tested;
449449 `renderer` and `hud` are checked against recording contexts, which is how a
450450 frame's draws are counted without a GPU — and how a lost context is tested
451451 too, since the recorder's canvas takes listeners and a test can fire
452452 `webglcontextlost` at it. Every GL resource the renderer owns comes from one
453453 `build(gl)`, so the restore is that same function a second time. `audio`
454 needs a real `AudioContext` and has no test of its own yet — that is #80.
454 gets the same treatment against a recording `AudioContext`: `createAudio`
455 takes its context constructor as an argument, so `audio.test.ts` supplies a
456 fake that logs every node it creates, every `connect`, every `.value` set
457 and every scheduled ramp, in place of the browser default
458 (`window.AudioContext ?? webkitAudioContext`) `main.ts` still gets.
455459 `weights` is `scripts/train.ts`'s output, not code anyone writes by hand, so
456460 there is nothing in it to assert beyond the numbers it happened to train to.
457461 - `src/rl/` — training, which the game never imports: `arena` is the environment
src/client/audio.test.ts added +361
@@ -0,0 +1,361 @@
1 /**
2 * `audio.ts` against a recording `AudioContext`: no browser, no speaker, just
3 * the graph it would have built — the same trick `renderer.test.ts` plays on
4 * WebGL2, one level down. `createAudio` takes its context constructor as an
5 * argument, so the fake below stands in for `window.AudioContext`.
6 */
7
8 import { expect, test } from "bun:test";
9 import { createAudio, type WorldSoundName } from "./audio.ts";
10 import { ears } from "./ears.ts";
11 import type { Vec3 } from "./math.ts";
12
13 type Event = { method: string; value: number; time: number };
14
15 /** A minimal AudioParam: `.value` sets and the two ramps `audio.ts` schedules
16 * are both logged, in order, so a test can tell a sound's starting loudness
17 * (its first event) from where the ramp leaves it (its `.value`). */
18 class FakeParam {
19 #value: number;
20 readonly events: Event[] = [];
21 constructor(initial: number) {
22 this.#value = initial;
23 }
24 get value(): number {
25 return this.#value;
26 }
27 set value(v: number) {
28 this.#value = v;
29 this.events.push({ method: "value", value: v, time: NaN });
30 }
31 setValueAtTime(value: number, time: number): FakeParam {
32 this.#value = value;
33 this.events.push({ method: "setValueAtTime", value, time });
34 return this;
35 }
36 exponentialRampToValueAtTime(value: number, time: number): FakeParam {
37 this.#value = value;
38 this.events.push({ method: "exponentialRampToValueAtTime", value, time });
39 return this;
40 }
41 }
42
43 type Edge = { from: number; to: number };
44
45 class FakeNode {
46 readonly id: number;
47 readonly kind: string;
48 #onConnect: (edge: Edge) => void;
49 constructor(id: number, kind: string, onConnect: (edge: Edge) => void) {
50 this.id = id;
51 this.kind = kind;
52 this.#onConnect = onConnect;
53 }
54 connect(dest: FakeNode): FakeNode {
55 this.#onConnect({ from: this.id, to: dest.id });
56 return dest;
57 }
58 }
59
60 class FakeGain extends FakeNode {
61 readonly gain = new FakeParam(1);
62 constructor(id: number, onConnect: (edge: Edge) => void) {
63 super(id, "gain", onConnect);
64 }
65 }
66
67 class FakeStereoPanner extends FakeNode {
68 readonly pan = new FakeParam(0);
69 constructor(id: number, onConnect: (edge: Edge) => void) {
70 super(id, "panner", onConnect);
71 }
72 }
73
74 class FakeBiquadFilter extends FakeNode {
75 type = "lowpass";
76 readonly frequency = new FakeParam(350);
77 readonly Q = new FakeParam(1);
78 constructor(id: number, onConnect: (edge: Edge) => void) {
79 super(id, "filter", onConnect);
80 }
81 }
82
83 class FakeBufferSource extends FakeNode {
84 buffer: AudioBuffer | null = null;
85 readonly playbackRate = new FakeParam(1);
86 #onStart: (id: number) => void;
87 #onStop: (id: number) => void;
88 constructor(id: number, onConnect: (edge: Edge) => void, onStart: (id: number) => void, onStop: (id: number) => void) {
89 super(id, "bufferSource", onConnect);
90 this.#onStart = onStart;
91 this.#onStop = onStop;
92 }
93 start(_time: number): void {
94 this.#onStart(this.id);
95 }
96 stop(_time: number): void {
97 this.#onStop(this.id);
98 }
99 }
100
101 class FakeOscillator extends FakeNode {
102 type = "sine";
103 readonly frequency = new FakeParam(440);
104 #onStart: (id: number) => void;
105 #onStop: (id: number) => void;
106 constructor(id: number, onConnect: (edge: Edge) => void, onStart: (id: number) => void, onStop: (id: number) => void) {
107 super(id, "oscillator", onConnect);
108 this.#onStart = onStart;
109 this.#onStop = onStop;
110 }
111 start(_time: number): void {
112 this.#onStart(this.id);
113 }
114 stop(_time: number): void {
115 this.#onStop(this.id);
116 }
117 }
118
119 class FakeAudioContext {
120 readonly sampleRate = 44100;
121 currentTime = 0;
122 state: "running" | "suspended" = "suspended";
123 readonly destination: FakeNode;
124 readonly nodes: FakeNode[] = [];
125 readonly edges: Edge[] = [];
126 readonly started: number[] = [];
127 readonly stopped: number[] = [];
128 #seq = 0;
129
130 constructor() {
131 this.destination = new FakeNode(this.#next(), "destination", (e) => this.edges.push(e));
132 }
133 #next(): number {
134 this.#seq += 1;
135 return this.#seq;
136 }
137 #register<T extends FakeNode>(node: T): T {
138 this.nodes.push(node);
139 return node;
140 }
141 createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer {
142 const data = new Float32Array(length);
143 return { getChannelData: () => data, length, sampleRate, numberOfChannels: channels } as unknown as AudioBuffer;
144 }
145 createGain(): FakeGain {
146 return this.#register(new FakeGain(this.#next(), (e) => this.edges.push(e)));
147 }
148 createStereoPanner(): FakeStereoPanner {
149 return this.#register(new FakeStereoPanner(this.#next(), (e) => this.edges.push(e)));
150 }
151 createBiquadFilter(): FakeBiquadFilter {
152 return this.#register(new FakeBiquadFilter(this.#next(), (e) => this.edges.push(e)));
153 }
154 createBufferSource(): FakeBufferSource {
155 return this.#register(
156 new FakeBufferSource(this.#next(), (e) => this.edges.push(e), (id) => this.started.push(id), (id) => this.stopped.push(id)),
157 );
158 }
159 createOscillator(): FakeOscillator {
160 return this.#register(
161 new FakeOscillator(this.#next(), (e) => this.edges.push(e), (id) => this.started.push(id), (id) => this.stopped.push(id)),
162 );
163 }
164 resume(): Promise<void> {
165 this.state = "running";
166 return Promise.resolve();
167 }
168 }
169
170 /** A constructor `createAudio` can call with `new`, plus every instance it built. */
171 function recorder(): { Ctor: typeof AudioContext; instances: FakeAudioContext[] } {
172 const instances: FakeAudioContext[] = [];
173 class Ctor extends FakeAudioContext {
174 constructor() {
175 super();
176 instances.push(this);
177 }
178 }
179 return { Ctor: Ctor as unknown as typeof AudioContext, instances };
180 }
181
182 const last = (ctx: FakeAudioContext, kind: string): FakeNode =>
183 ctx.nodes.filter((n) => n.kind === kind).at(-1) as FakeNode;
184
185 const LISTENER: Vec3 = [0, 0, 0];
186
187 test("nothing is created before the first sound or resume()", () => {
188 const { Ctor, instances } = recorder();
189 createAudio(Ctor);
190 expect(instances.length).toBe(0);
191 });
192
193 test("resume() is what creates a context built at page load would otherwise need", () => {
194 const { Ctor, instances } = recorder();
195 const audio = createAudio(Ctor);
196 audio.resume();
197 expect(instances.length).toBe(1);
198 expect(instances[0]?.state).toBe("running");
199 });
200
201 test("the first sound creates the context lazily, exactly once", () => {
202 const { Ctor, instances } = recorder();
203 const audio = createAudio(Ctor);
204 audio.beep(900);
205 expect(instances.length).toBe(1);
206 audio.beep(900);
207 audio.shot("ak47", [10, 0, 0], LISTENER, 0);
208 expect(instances.length).toBe(1);
209 });
210
211 test("muted creates no nodes at all, and setMuted(false) brings the sound back", () => {
212 const { Ctor, instances } = recorder();
213 const audio = createAudio(Ctor);
214 audio.setMuted(true);
215 audio.shot("ak47", [10, 0, 0], LISTENER, 0);
216 audio.step([10, 0, 0], LISTENER, 0);
217 audio.beep(900);
218 audio.pain(20);
219 audio.world("beep", [10, 0, 0], LISTENER, 0);
220 expect(instances.length).toBe(0);
221
222 audio.setMuted(false);
223 audio.beep(900);
224 expect(instances.length).toBe(1);
225 expect(instances[0]?.nodes.length).toBeGreaterThan(0);
226 });
227
228 test("a volume set before the first sound is applied once master exists", () => {
229 const { Ctor, instances } = recorder();
230 const audio = createAudio(Ctor);
231 audio.setVolume(0.4);
232 audio.beep(900);
233 const master = instances[0]?.nodes[0] as FakeGain;
234 expect(master.kind).toBe("gain");
235 expect(master.gain.value).toBeCloseTo(0.4, 6);
236 });
237
238 test("setVolume(NaN) keeps the previous volume", () => {
239 const { Ctor, instances } = recorder();
240 const audio = createAudio(Ctor);
241 audio.setVolume(0.7);
242 audio.setVolume(NaN);
243 audio.beep(900);
244 const master = instances[0]?.nodes[0] as FakeGain;
245 expect(master.gain.value).toBeCloseTo(0.7, 6);
246 });
247
248 test("volume is clamped to 0..1", () => {
249 const above = recorder();
250 const audioAbove = createAudio(above.Ctor);
251 audioAbove.setVolume(5);
252 audioAbove.beep(900);
253 expect((above.instances[0]?.nodes[0] as FakeGain).gain.value).toBe(1);
254
255 const below = recorder();
256 const audioBelow = createAudio(below.Ctor);
257 audioBelow.setVolume(-3);
258 audioBelow.beep(900);
259 expect((below.instances[0]?.nodes[0] as FakeGain).gain.value).toBe(0);
260 });
261
262 test("a shot to the left pans negative and to the right positive, agreeing with ears()", () => {
263 const { Ctor, instances } = recorder();
264 const audio = createAudio(Ctor);
265 const yaw = 0;
266 const leftPos: Vec3 = [100, 100, 0];
267 const rightPos: Vec3 = [100, -100, 0];
268
269 audio.shot("ak47", leftPos, LISTENER, yaw);
270 const leftPan = (last(instances[0] as FakeAudioContext, "panner") as FakeStereoPanner).pan.value;
271 expect(Math.sign(leftPan)).toBe(Math.sign(ears(leftPos, LISTENER, yaw).pan));
272
273 audio.shot("ak47", rightPos, LISTENER, yaw);
274 const rightPan = (last(instances[0] as FakeAudioContext, "panner") as FakeStereoPanner).pan.value;
275 expect(Math.sign(rightPan)).toBe(Math.sign(ears(rightPos, LISTENER, yaw).pan));
276 expect(Math.sign(leftPan)).not.toBe(Math.sign(rightPan));
277 });
278
279 test("a distant shot's gain is below a near one's", () => {
280 const { Ctor, instances } = recorder();
281 const audio = createAudio(Ctor);
282 const ctx = () => instances[0] as FakeAudioContext;
283 const yaw = 0;
284
285 audio.shot("ak47", [50, 0, 0], LISTENER, yaw);
286 const nearGain = (last(ctx(), "gain") as FakeGain).gain.events[0]?.value as number;
287
288 audio.shot("ak47", [3000, 0, 0], LISTENER, yaw);
289 const farGain = (last(ctx(), "gain") as FakeGain).gain.events[0]?.value as number;
290
291 expect(farGain).toBeLessThan(nearGain);
292 });
293
294 test("pain twice inside 140ms plays once, and after the window it plays again", () => {
295 const { Ctor, instances } = recorder();
296 const audio = createAudio(Ctor);
297 audio.pain(20);
298 const ctx = instances[0] as FakeAudioContext;
299 const afterFirst = ctx.nodes.length;
300 expect(afterFirst).toBeGreaterThan(0);
301
302 ctx.currentTime = 0.1; // inside the 140ms window
303 audio.pain(20);
304 expect(ctx.nodes.length).toBe(afterFirst); // nothing new
305
306 ctx.currentTime = 0.3; // outside it
307 audio.pain(20);
308 expect(ctx.nodes.length).toBeGreaterThan(afterFirst);
309 });
310
311 test('world("beep", …, pitch) uses the pitch it is given, and the table\'s frequency when it is not', () => {
312 const { Ctor, instances } = recorder();
313 const audio = createAudio(Ctor);
314 const ctx = () => instances[0] as FakeAudioContext;
315
316 audio.world("beep", [10, 0, 0], LISTENER, 0, 1200);
317 expect((last(ctx(), "oscillator") as FakeOscillator).frequency.events[0]?.value).toBe(1200);
318
319 audio.world("beep", [10, 0, 0], LISTENER, 0);
320 expect((last(ctx(), "oscillator") as FakeOscillator).frequency.events[0]?.value).toBe(900); // WORLD_TONE.beep.freq
321 });
322
323 test("explode and blast take the same path, and ignore the pitch a beep would use", () => {
324 const { Ctor, instances } = recorder();
325 const audio = createAudio(Ctor);
326 const ctx = () => instances[0] as FakeAudioContext;
327
328 for (const name of ["explode", "blast"] as const) {
329 audio.world(name, [10, 0, 0], LISTENER, 0, 5000);
330 const boom = last(ctx(), "oscillator") as FakeOscillator;
331 expect(boom.frequency.events[0]?.value).toBe(140);
332 const tail = last(ctx(), "bufferSource") as FakeBufferSource;
333 expect(tail.buffer).not.toBeNull();
334 }
335 });
336
337 test("a name with no tone and no special case creates nothing but the panner", () => {
338 const { Ctor, instances } = recorder();
339 const audio = createAudio(Ctor);
340 audio.beep(900); // gets the context and its master gain out of the way
341 const ctx = instances[0] as FakeAudioContext;
342 const before = ctx.nodes.length;
343
344 audio.world("nope" as unknown as WorldSoundName, [10, 0, 0], LISTENER, 0);
345 expect(ctx.nodes.length).toBe(before + 1);
346 expect(ctx.nodes[ctx.nodes.length - 1]?.kind).toBe("panner");
347 });
348
349 test("a hundred shots cost a fixed number of nodes, and every source and oscillator that starts is stopped", () => {
350 const { Ctor, instances } = recorder();
351 const audio = createAudio(Ctor);
352 for (let i = 0; i < 100; i += 1) audio.shot("ak47", [i, 0, 0], LISTENER, 0);
353
354 const ctx = instances[0] as FakeAudioContext;
355 // One master gain, plus a panner, a source, a filter, a gain, an
356 // oscillator and its own gain per shot: 1 + 100 * 6, not one node more.
357 expect(ctx.nodes.length).toBe(1 + 100 * 6);
358 expect(ctx.started.length).toBe(200); // the noise burst and the thump, per shot
359 expect(ctx.stopped.length).toBe(ctx.started.length);
360 expect(new Set(ctx.stopped)).toEqual(new Set(ctx.started));
361 });
src/client/audio.ts +11 −2
@@ -54,7 +54,17 @@ const WORLD_TONE: Partial<Record<WorldSoundName, { freq: number; type: Oscillato
5454
5555 type Live = { ctx: AudioContext; noise: AudioBuffer; master: GainNode };
5656
57 export function createAudio(): Audio {
57 /** The browser default: `webkitAudioContext` is Safari's old name for it. */
58 const defaultContext = (): typeof AudioContext | undefined =>
59 window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
60
61 /**
62 * `Ctor` is the seam a test supplies a recording `AudioContext` through — the
63 * same trick `renderer.test.ts` plays on `getContext`. Nothing calls it
64 * before the first sound or `resume()`, since a context built at page load is
65 * one the browser suspends anyway.
66 */
67 export function createAudio(Ctor: typeof AudioContext | undefined = defaultContext()): Audio {
5868 let live: Live | null = null;
5969 let lastPain = -1;
6070 let volume = 1;
@@ -68,7 +78,6 @@ export function createAudio(): Audio {
6878 // first click just waits here for `master` to exist to apply itself to.
6979 const ensure = (): Live | null => {
7080 if (live === null) {
71 const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
7281 if (Ctor === undefined) return null;
7382 const ctx = new Ctor();
7483 const noise = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs