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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/80-audio-ts-has-never-been-tested-a-recordi → main 1 commit fork
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. | ||
| 445 | 445 | `settings` (mute, volume, sensitivity and difficulty: read, validated, and |
| 446 | 446 | never NaN or an unknown name), |
| 447 | 447 | `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; | |
| 449 | 449 | `renderer` and `hud` are checked against recording contexts, which is how a |
| 450 | 450 | frame's draws are counted without a GPU — and how a lost context is tested |
| 451 | 451 | too, since the recorder's canvas takes listeners and a test can fire |
| 452 | 452 | `webglcontextlost` at it. Every GL resource the renderer owns comes from one |
| 453 | 453 | `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. | |
| 455 | 459 | `weights` is `scripts/train.ts`'s output, not code anyone writes by hand, so |
| 456 | 460 | there is nothing in it to assert beyond the numbers it happened to train to. |
| 457 | 461 | - `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 | : number; | |
| 20 | readonly events: Event[] = []; | |
| 21 | constructor(initial: number) { | |
| 22 | this. = initial; | |
| 23 | } | |
| 24 | get value(): number { | |
| 25 | return this.; | |
| 26 | } | |
| 27 | set value(v: number) { | |
| 28 | this. = v; | |
| 29 | this.events.push({ method: "value", value: v, time: NaN }); | |
| 30 | } | |
| 31 | setValueAtTime(value: number, time: number): FakeParam { | |
| 32 | this. = value; | |
| 33 | this.events.push({ method: "setValueAtTime", value, time }); | |
| 34 | return this; | |
| 35 | } | |
| 36 | exponentialRampToValueAtTime(value: number, time: number): FakeParam { | |
| 37 | this. = 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 | : (edge: Edge) => void; | |
| 49 | constructor(id: number, kind: string, onConnect: (edge: Edge) => void) { | |
| 50 | this.id = id; | |
| 51 | this.kind = kind; | |
| 52 | this. = onConnect; | |
| 53 | } | |
| 54 | connect(dest: FakeNode): FakeNode { | |
| 55 | this.({ 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 | : (id: number) => void; | |
| 87 | : (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; | |
| 91 | this. = onStop; | |
| 92 | } | |
| 93 | start(_time: number): void { | |
| 94 | this.(this.id); | |
| 95 | } | |
| 96 | stop(_time: number): void { | |
| 97 | this.(this.id); | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | class FakeOscillator extends FakeNode { | |
| 102 | type = "sine"; | |
| 103 | readonly frequency = new FakeParam(440); | |
| 104 | : (id: number) => void; | |
| 105 | : (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; | |
| 109 | this. = onStop; | |
| 110 | } | |
| 111 | start(_time: number): void { | |
| 112 | this.(this.id); | |
| 113 | } | |
| 114 | stop(_time: number): void { | |
| 115 | this.(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 | = 0; | |
| 129 | ||
| 130 | constructor() { | |
| 131 | this.destination = new FakeNode(this.(), "destination", (e) => this.edges.push(e)); | |
| 132 | } | |
| 133 | (): number { | |
| 134 | this. += 1; | |
| 135 | return this.; | |
| 136 | } | |
| 137 | <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.(new FakeGain(this.(), (e) => this.edges.push(e))); | |
| 147 | } | |
| 148 | createStereoPanner(): FakeStereoPanner { | |
| 149 | return this.(new FakeStereoPanner(this.(), (e) => this.edges.push(e))); | |
| 150 | } | |
| 151 | createBiquadFilter(): FakeBiquadFilter { | |
| 152 | return this.(new FakeBiquadFilter(this.(), (e) => this.edges.push(e))); | |
| 153 | } | |
| 154 | createBufferSource(): FakeBufferSource { | |
| 155 | return this.( | |
| 156 | new FakeBufferSource(this.(), (e) => this.edges.push(e), (id) => this.started.push(id), (id) => this.stopped.push(id)), | |
| 157 | ); | |
| 158 | } | |
| 159 | createOscillator(): FakeOscillator { | |
| 160 | return this.( | |
| 161 | new FakeOscillator(this.(), (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 | ||
| 54 | 54 | |
| 55 | 55 | type Live = { ctx: AudioContext; noise: AudioBuffer; master: GainNode }; |
| 56 | 56 | |
| 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 { | |
| 58 | 68 | let live: Live | null = null; |
| 59 | 69 | let lastPain = -1; |
| 60 | 70 | let volume = 1; |
| @@ -68,7 +78,6 @@ export function createAudio(): Audio { | ||
| 68 | 78 | // first click just waits here for `master` to exist to apply itself to. |
| 69 | 79 | const ensure = (): Live | null => { |
| 70 | 80 | if (live === null) { |
| 71 | const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; | |
| 72 | 81 | if (Ctor === undefined) return null; |
| 73 | 82 | const ctx = new Ctor(); |
| 74 | 83 | const noise = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate); |
new above
offline