Public: anyone can read this; the crew can change it.

forked from trevato/cs

A sound says which side it came from: the ear has a heading, and the engine pans what it hears #217

Merged opened this

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

Merged

torque/cs-engine:torque/209-a-sound-says-which-side-it-came-from-the → main 1 commit fork

  • Merged. torque/209-a-sound-says-which-side-it-came-from-the was merged into main as 7442c30.

Review

Sign in to review this change.
17 files changed +466 −107
src/client/audio.test.ts +80
@@ -147,3 +147,83 @@ test("resuming the voice resumes the device", () => {
147147 createAudio(device).resume();
148148 expect(device.resumes).toBe(1);
149149 });
150
151 // --- which side it came from ----------------------------------------------
152
153 /**
154 * North is +Y, so an ear facing north is at yaw 90, and from there west (−X) is
155 * its left and east (+X) is its right. 300 units is well past the engine's
156 * softening radius, so these are the full swing: a step that far to one side
157 * comes out of that speaker and nowhere near the other.
158 */
159 const NORTH = 90;
160
161 test("a footstep to the left of a facing ear is negative, to the right positive, ahead zero", () => {
162 const device = recorder();
163 const audio = createAudio(device);
164 audio.play("step", [-300, 0, 0], [0, 0, 0], false, NORTH);
165 audio.play("step", [300, 0, 0], [0, 0, 0], false, NORTH);
166 audio.play("step", [0, 300, 0], [0, 0, 0], false, NORTH);
167 const [left, right, ahead] = device.bursts as [Burst, Burst, Burst];
168 expect(left.pan).toBeLessThan(0);
169 expect(right.pan).toBeGreaterThan(0);
170 expect(ahead.pan).toBeCloseTo(0, 12);
171 // Hard over, both of them, and mirrored: 300 units is a long way past a head.
172 expect(left.pan).toBeCloseTo(-1, 12);
173 expect(right.pan).toBeCloseTo(1, 12);
174 // The thump under the step is placed with it, or the two halves of one
175 // footstep come out of different speakers.
176 expect((device.tones[0] as Tone).pan).toBe(left.pan);
177 });
178
179 test("turning to face the sound brings it to the middle, and turning past it swaps sides", () => {
180 const device = recorder();
181 const audio = createAudio(device);
182 // The same step due east of the ear, heard while facing east, north and south.
183 for (const yaw of [0, 90, -90]) audio.play("step", [300, 0, 0], [0, 0, 0], false, yaw);
184 const [facing, backTo, turnedAway] = device.bursts as [Burst, Burst, Burst];
185 expect(facing.pan).toBeCloseTo(0, 12); // straight at it
186 expect(backTo.pan).toBeCloseTo(1, 12); // right shoulder
187 expect(turnedAway.pan).toBeCloseTo(-1, 12); // left shoulder
188 });
189
190 test("a shot is placed the same way, and every node of a noise carries one pan", () => {
191 const device = recorder();
192 const audio = createAudio(device);
193 audio.shot("ak47", [-800, 0, 0], [0, 0, 0], NORTH);
194 expect((device.bursts[0] as Burst).pan).toBeCloseTo(-1, 12);
195 expect((device.tones[0] as Tone).pan).toBeCloseTo(-1, 12);
196
197 // Every recipe places every voice it builds: a name that forgot would leave
198 // half a noise in the middle while the other half is off to one side.
199 const names: Sound[] = ["step", "beep", "plant", "defuse", "death", "throw", "bounce", "blast", "flash"];
200 for (const name of names) {
201 const one = recorder();
202 createAudio(one).play(name, [900, 0, 0], [0, 0, 0], false, NORTH);
203 const voices = [...one.bursts, ...one.tones];
204 expect(voices.length).toBeGreaterThan(0);
205 for (const voice of voices) expect([name, voice.pan]).toEqual([name, expect.closeTo(1, 12)]);
206 }
207 });
208
209 test("your own boots and your own gun stay in the middle", () => {
210 const device = recorder();
211 const audio = createAudio(device);
212 // A noise you made is passed no pan at all: there is no side for it.
213 audio.play("step", [0, 0, 0], [0, 0, 0], true, NORTH);
214 expect((device.bursts[0] as Burst).pan).toBeUndefined();
215 expect((device.tones[0] as Tone).pan).toBeUndefined();
216 // Your own gun fires from the ear it is heard with, so it works out centred
217 // without a special case.
218 audio.shot("ak47", [0, 0, 0], [0, 0, 0], NORTH);
219 expect((device.bursts[1] as Burst).pan).toBe(0);
220 });
221
222 test("a caller that names no heading is the game as it was: every sound ahead-and-centre of due east", () => {
223 const device = recorder();
224 const audio = createAudio(device);
225 // No yaw given is yaw 0 — facing +X — which is the old behaviour for anything
226 // directly in front, and still a real pan for anything off to a side.
227 audio.play("blast", [1000, 0, 0], [0, 0, 0]);
228 expect((device.bursts[0] as Burst).pan).toBeCloseTo(0, 12);
229 });
src/client/audio.ts +43 −23
@@ -6,19 +6,29 @@
66 *
77 * Everything the round itself makes — a foot landing, the bomb's beep, the
88 * plant, the cut wire, a body going down — is one recipe per name under `play`,
9 * positioned through the engine's `attenuate` from the ear the frame is
10 * listening with. The distances are in Source units (1 unit ≈ 1.9 cm) and are
9 * positioned through the engine's `attenuate` and `panOf` from the ear the
10 * frame is listening with: how far away it is sets the gain, and which side of
11 * the ear's heading it is on sets the pan. Front and back are not distinguished
12 * and cannot be with two speakers — a step ahead and a step behind are both
13 * centre, and that is the whole of what this device claims.
14 *
15 * The distances are in Source units (1 unit ≈ 1.9 cm) and are
1116 * the numbers below: a sound is at its recipe's full gain inside its earshot
1217 * and falls as `earshot / distance` past it, so a footstep at 256 units is as
1318 * loud as a footstep can be and the same one at 1,024 is a quarter of that.
1419 */
1520
16 import { attenuate, createSoundDevice, dist, type SoundDevice, type Vec3 } from "../engine/index.ts";
21 import { attenuate, createSoundDevice, dist, panOf, type SoundDevice, type Vec3 } from "../engine/index.ts";
1722 import { WEAPONS, type WeaponId } from "./weapons.ts";
1823 import type { Sound } from "./world.ts";
1924
2025 export type Audio = {
21 shot(weapon: WeaponId, at: Vec3, listener: Vec3): void;
26 /**
27 * A weapon going off at `at`, heard from `listener` facing `yaw` degrees.
28 * Your own gun needs no special case: it is fired from the ear it is heard
29 * with, so `panOf` softens it to dead centre on its own.
30 */
31 shot(weapon: WeaponId, at: Vec3, listener: Vec3, yaw?: number): void;
2232 /**
2333 * One noise of the round, by name, placed where it happened and heard from
2434 * `listener`. `own` is true when the body that made it is the body these ears
@@ -28,8 +38,12 @@ export type Audio = {
2838 * One path widened by a name rather than a voice per noise: a grenade leaving
2939 * the hand and a foot landing are the same two nodes with different numbers
3040 * in them, and the day a sixth round noise is wanted it is a `case` here.
41 *
42 * `yaw` is the ear's heading in degrees; a noise you made yourself is passed
43 * no pan at all, because your own boots come from where you are standing and
44 * a panner on them is a node that does nothing.
3145 */
32 play(sound: Sound, at: Vec3, listener: Vec3, own?: boolean): void;
46 play(sound: Sound, at: Vec3, listener: Vec3, own?: boolean, yaw?: number): void;
3347 hit(headshot: boolean): void;
3448 beep(pitch: number, gain?: number): void;
3549 resume(): void;
@@ -107,75 +121,81 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
107121 device.muted = value;
108122 },
109123 resume: () => device.resume(),
110 shot(weapon, at, listener) {
124 shot(weapon, at, listener, yaw = 0) {
111125 const tone = shotTone(weapon);
112126 const volume = loudness(at, listener);
127 const pan = panOf(at, listener, yaw);
113128 device.noiseBurst({
114129 frequency: tone.cut,
115130 gain: 0.35 * volume,
116131 length: tone.length,
117132 q: 0.9,
118133 rate: 1 + Math.random() * 0.15,
134 pan,
119135 });
120136 device.tone({
121137 frequency: tone.thump,
122138 slideTo: tone.thump * 0.4,
123139 gain: 0.3 * volume,
124140 length: tone.length,
141 pan,
125142 });
126143 },
127 play(sound, at, listener, own = false) {
144 play(sound, at, listener, own = false, yaw = 0) {
128145 const far = dist(at, listener);
146 // Your own noise is not placed: it happens at the ear, so there is no
147 // side for it to be on and no panner worth building for it.
148 const pan = own ? undefined : panOf(at, listener, yaw);
129149 if (sound === "step") {
130150 // A foot landing: a scuff of grit through a wide filter with a soft
131151 // thump under it. The rate is jittered so a body at a dead run is a
132152 // walk and not one sample played twelve times.
133153 const volume = attenuate(far, STEP_EARSHOT, STEP_CLOSE) * (own ? OWN_STEP : 1);
134 device.noiseBurst({ frequency: 520, gain: STEP_GAIN * volume, length: 0.1, q: 1.1, rate: 0.85 + Math.random() * 0.3 });
135 device.tone({ frequency: 95, slideTo: 55, gain: 0.16 * volume, length: 0.09 });
154 device.noiseBurst({ frequency: 520, gain: STEP_GAIN * volume, length: 0.1, q: 1.1, rate: 0.85 + Math.random() * 0.3, pan });
155 device.tone({ frequency: 95, slideTo: 55, gain: 0.16 * volume, length: 0.09, pan });
136156 return;
137157 }
138158 if (sound === "beep") {
139159 // The bomb, counting. A square tone and nothing under it: it has to cut
140160 // through a firefight, and it is the one sound here that repeats.
141161 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
142 device.tone({ frequency: 2200, gain: 0.2 * volume, length: 0.07, type: "square", floor: 0.0005, tail: 0.01 });
162 device.tone({ frequency: 2200, gain: 0.2 * volume, length: 0.07, type: "square", floor: 0.0005, tail: 0.01, pan });
143163 return;
144164 }
145165 if (sound === "plant") {
146166 // The bomb going down: the clunk of it landing, and a tone that climbs
147167 // — the round just turned over, and it turned the terrorists' way.
148168 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
149 device.noiseBurst({ frequency: 1400, gain: 0.3 * volume, length: 0.09, q: 5, rate: 1 });
150 device.tone({ frequency: 300, slideTo: 900, gain: 0.26 * volume, length: 0.3 });
169 device.noiseBurst({ frequency: 1400, gain: 0.3 * volume, length: 0.09, q: 5, rate: 1, pan });
170 device.tone({ frequency: 300, slideTo: 900, gain: 0.26 * volume, length: 0.3, pan });
151171 return;
152172 }
153173 if (sound === "defuse") {
154174 // The wire, cut: a bright snip and a tone that falls away under it. It
155175 // is the plant's noise backwards on purpose — the same event, undone.
156176 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
157 device.noiseBurst({ frequency: 2600, gain: 0.26 * volume, length: 0.07, q: 6, rate: 1 });
158 device.tone({ frequency: 900, slideTo: 300, gain: 0.26 * volume, length: 0.3 });
177 device.noiseBurst({ frequency: 2600, gain: 0.26 * volume, length: 0.07, q: 6, rate: 1, pan });
178 device.tone({ frequency: 900, slideTo: 300, gain: 0.26 * volume, length: 0.3, pan });
159179 return;
160180 }
161181 if (sound === "death") {
162182 // A body hitting the floor: low, wide, short, with a thump that falls
163183 // away. No cry — the game has no voices, and a fall carries further.
164184 const volume = attenuate(far, DEATH_EARSHOT, DEATH_CLOSE);
165 device.noiseBurst({ frequency: 240, gain: 0.34 * volume, length: 0.35, q: 0.8, rate: 0.8 });
166 device.tone({ frequency: 110, slideTo: 50, gain: 0.24 * volume, length: 0.3 });
185 device.noiseBurst({ frequency: 240, gain: 0.34 * volume, length: 0.35, q: 0.8, rate: 0.8, pan });
186 device.tone({ frequency: 110, slideTo: 50, gain: 0.24 * volume, length: 0.3, pan });
167187 return;
168188 }
169189 if (sound === "throw") {
170190 // A short upward whoosh of air: quiet, and only near you.
171 device.noiseBurst({ frequency: 2400, gain: 0.14 * loudness(at, listener), length: 0.16, q: 0.7, rate: 1.4 });
191 device.noiseBurst({ frequency: 2400, gain: 0.14 * loudness(at, listener), length: 0.16, q: 0.7, rate: 1.4, pan });
172192 return;
173193 }
174194 if (sound === "bounce") {
175195 // A hard tap: the shell against concrete, tight and short.
176196 const volume = loudness(at, listener);
177 device.noiseBurst({ frequency: 1200, gain: 0.22 * volume, length: 0.07, q: 4, rate: 1 });
178 device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08 });
197 device.noiseBurst({ frequency: 1200, gain: 0.22 * volume, length: 0.07, q: 4, rate: 1, pan });
198 device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08, pan });
179199 return;
180200 }
181201 if (sound === "flash") {
@@ -185,15 +205,15 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
185205 // ears. It carries as far as the blast does, and it matters over a
186206 // wider circle than one, because so does the light.
187207 const bright = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
188 device.noiseBurst({ frequency: 5200, gain: 0.55 * bright, length: 0.28, q: 1.2, rate: 1.5 });
189 device.tone({ frequency: 2600, slideTo: 4200, gain: 0.3 * bright, length: 0.5 });
208 device.noiseBurst({ frequency: 5200, gain: 0.55 * bright, length: 0.28, q: 1.2, rate: 1.5, pan });
209 device.tone({ frequency: 2600, slideTo: 4200, gain: 0.3 * bright, length: 0.5, pan });
190210 return;
191211 }
192212 // The blast: a wide, low body of noise with a thump under it that falls
193213 // an octave and a half as it goes.
194214 const volume = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
195 device.noiseBurst({ frequency: 360, gain: 0.7 * volume, length: 1.1, q: 0.35, rate: 0.6 });
196 device.tone({ frequency: 130, slideTo: 42, gain: 0.55 * volume, length: 0.85 });
215 device.noiseBurst({ frequency: 360, gain: 0.7 * volume, length: 1.1, q: 0.35, rate: 0.6, pan });
216 device.tone({ frequency: 130, slideTo: 42, gain: 0.55 * volume, length: 0.85, pan });
197217 },
198218 hit(headshot) {
199219 audio.beep(headshot ? 1400 : 900, 0.08);
src/client/main.test.ts +18
@@ -209,6 +209,24 @@ test("nothing is heard before a match, and then your own boots are heard as your
209209 expect(stage.sounds).toContain("sound:step");
210210 });
211211
212 test("every sound is placed from the ear's heading, not just its position", () => {
213 const stage = painted(start(fakeStage()));
214 stage.by("play").fire("click");
215 const world = (stage.step(FRAME), worldOf(stage)) as World;
216 world.match.phase = "live";
217 world.match.timer = 60;
218 stage.lock(true);
219 // A heading nothing else in the game would produce, so a yaw that arrives by
220 // accident — a zero, a bot's, the camera's own — is not this one.
221 world.me.yaw = 137;
222 stage.hold("KeyW", true);
223 for (let frame = 0; frame < 40; frame++) stage.step(FRAME);
224 expect(stage.sounds.length).toBeGreaterThan(0);
225 // Running forward does not turn you, so every noise of that second was heard
226 // from the same facing, and it is the one the player is holding.
227 expect(new Set(stage.headings)).toEqual(new Set([137]));
228 });
229
212230 test("one frame of 1/30 s runs two 64 Hz steps and no more, and banks the rest", () => {
213231 const stage = playing();
214232 stage.step(FRAME);
src/client/main.ts +6 −3
@@ -747,7 +747,9 @@ export function boot(host: Host): Frame {
747747
748748 /**
749749 * Sound for whatever happened in the ticks we just ran, heard from `ears` —
750 * the player the camera is behind. Dead, that is whoever you are watching:
750 * the player the camera is behind, facing the way that player faces: the ear
751 * has a heading as well as a place, and a noise off to one side comes out of
752 * the speaker on that side. Dead, that is whoever you are watching:
751753 * a round heard from your own corpse is the other half of a broken spectate.
752754 * Which is also why "your own" footstep is the one `ears` took and not the
753755 * one your body took: whose ears these are is the frame's question, and the
@@ -757,8 +759,9 @@ export function boot(host: Host): Frame {
757759 for (let i = 0; i < current.effects.count; i++) {
758760 const effect = effectAt(current.effects, i);
759761 if (effect === undefined || effect.at <= lastSound) continue;
760 if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears));
761 else if (effect.kind === "sound") audio.play(effect.name, effect.pos, eyePos(ears), effect.by === ears.id);
762 if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears), ears.yaw);
763 else if (effect.kind === "sound")
764 audio.play(effect.name, effect.pos, eyePos(ears), effect.by === ears.id, ears.yaw);
762765 else if (effect.kind === "hitmark") audio.hit(effect.headshot);
763766 }
764767 lastSound = current.time;
src/client/stage.test-helper.ts +17 −3
@@ -139,6 +139,13 @@ export type FakeStage = {
139139 atlases: Uint8Array[];
140140 /** Every sound asked for, as `shot:ak47`, `hit`, `beep` or `resume`. */
141141 sounds: string[];
142 /**
143 * The ear's heading handed to the voice with each placed sound, in the order
144 * they were played. A sound is panned from it, so a frame that plays sound
145 * from one player's eye and hands over another's yaw is a round where every
146 * noise comes from the wrong side — and this is what says which yaw arrived.
147 */
148 headings: number[];
142149 store: Map<string, string>;
143150 /** The match ids handed out, newest last. */
144151 ids: string[];
@@ -215,6 +222,7 @@ export function fakeStage(
215222 const replaced: { name: string; mesh: Mesh }[] = [];
216223 const atlases: Uint8Array[] = [];
217224 const sounds: string[] = [];
225 const headings: number[] = [];
218226 const store = new Map<string, string>();
219227 const ids: string[] = [];
220228 const copied: string[] = [];
@@ -223,11 +231,17 @@ export function fakeStage(
223231 const audio: Audio = {
224232 muted: false,
225233 resume: () => void sounds.push("resume"),
226 shot: (weapon) => void sounds.push(`shot:${weapon}`),
234 shot: (weapon, _at, _listener, yaw = 0) => {
235 headings.push(yaw);
236 sounds.push(`shot:${weapon}`);
237 },
227238 // Every noise the round makes goes through this one, and it is written down
228239 // with whether the ears that heard it are the ones that made it: a stage
229240 // that boots the game without it is a stage where the first footstep throws.
230 play: (sound, _at, _listener, own) => void sounds.push(own === true ? `sound:${sound}:own` : `sound:${sound}`),
241 play: (sound, _at, _listener, own, yaw = 0) => {
242 headings.push(yaw);
243 sounds.push(own === true ? `sound:${sound}:own` : `sound:${sound}`);
244 },
231245 hit: (headshot) => void sounds.push(headshot ? "hit:head" : "hit"),
232246 beep: () => void sounds.push("beep"),
233247 };
@@ -284,7 +298,7 @@ export function fakeStage(
284298 hud,
285299 buttons: page.buttons,
286300 frames: page.frames,
287 posts, draws, replaced, atlases, sounds, store, ids, copied,
301 posts, draws, replaced, atlases, sounds, headings, store, ids, copied,
288302 refuseCopy: false,
289303 by: page.by,
290304 step: page.step,
src/engine/GUIDE.md +22
@@ -288,6 +288,28 @@ the map. `src/client/audio.ts` is the shooter's voice and `src/range/audio.ts`
288288 the range's, and they share no sound; the example opens no device at all, which
289289 is why a test can import it in a process with no `AudioContext` in it.
290290
291 The device is positional, in one dimension. Both `Burst` and `Tone` take an
292 optional `Burst.pan`, −1 hard left to +1 hard right, and a voice that carries
293 one is routed through a `StereoPannerNode` on its way to the destination.
294 `panOf` is the arithmetic that works one out, from a source, an ear and the
295 heading the ear faces. That heading is in degrees, the same angle
296 `angleVectors` takes, and the answer is which side of that facing the source is
297 on — −1 due left, +1 due right, 0 dead ahead, dead behind, straight overhead or
298 on top of the ear. Inside `sound.HEAD` (64 units, about a stride) the pan is
299 softened towards the centre in proportion, so a body walking through you does
300 not flip speakers in one frame. Front and back are not in the answer and cannot
301 be: two speakers say left and right, and the game does not pay for a head model
302 to pretend otherwise.
303
304 Two things that pass through this seam quietly. A pan is validated where it is
305 used — a NaN or a number outside −1..1 is clamped to centre, never handed to a
306 node — and a voice with **no** pan at all builds no panner and is wired exactly
307 as it was before panning existed, which is what the range does today. And a
308 context with no `createStereoPanner` — the oldest Safari, and every hand-written
309 double in this repository — connects straight to the destination instead of
310 throwing, so a fake that never grew the method plays the sound centred rather
311 than dropping the frame.
312
291313 ### loop
292314
293315 `createLoop` takes `LoopOptions` — the tick, and optionally the most steps one
src/engine/boundary.test.ts +1 −1
@@ -321,7 +321,7 @@ export const SURFACE = [
321321 "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside",
322322 "length", "length2d", "lerp",
323323 "logitCount", "mat4", "mergeSolid", "modelMatrix", "move", "mul", "multiply", "newActivations",
324 "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter",
324 "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter", "panOf",
325325 "paramCount", "perspective", "pushEffect", "pushLine", "quadMesh", "quantise", "rad", "rayBox",
326326 "region", "resetEffects", "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength",
327327 "softmax", "stuck", "sub",
src/engine/guide.test.ts +4 −2
@@ -61,9 +61,11 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
6161 * this file ignores without needing to be told about it.
6262 */
6363 const NOT_THE_ENGINES = new Set([
64 "AudioContext", "Float32Array", "Float64Array", "Int32Array", "TEXTURE_2D_ARRAY",
64 "AudioContext", "Float32Array", "Float64Array", "Int32Array", "StereoPannerNode",
65 "TEXTURE_2D_ARRAY",
6566 "addEventListener",
66 "cancelAnimationFrame", "de_forge", "fullscreenElement", "pointerLockElement",
67 "cancelAnimationFrame", "createStereoPanner", "de_forge", "fullscreenElement",
68 "pointerLockElement",
6769 "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv",
6870 ]);
6971
src/engine/index.ts +6 −2
@@ -43,8 +43,12 @@ export type { Builder, Mesh } from "./mesh.ts";
4343 export { fbm, hash, noise, paintAtlas, painter } from "./texture.ts";
4444 export type { Painter, Shader } from "./texture.ts";
4545
46 /** A lazy `AudioContext` with noise bursts, tones and distance attenuation. */
47 export { attenuate, createSoundDevice } from "./sound.ts";
46 /**
47 * A lazy `AudioContext` with noise bursts, tones, distance attenuation and one
48 * number of stereo placement: `panOf` says which side of a facing ear a sound
49 * is on, and a voice that carries a `pan` goes through a panner to get there.
50 */
51 export { attenuate, createSoundDevice, panOf } from "./sound.ts";
4852 export type { Burst, Ctor, SoundDevice, Tone } from "./sound.ts";
4953
5054 /** A fixed simulation step with a clamp on a long frame, clock supplied. */
src/engine/sound.test.ts +125 −4
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { attenuate, createSoundDevice, type Ctor } from "./sound.ts";
2 import { attenuate, createSoundDevice, HEAD, panOf, type Ctor } from "./sound.ts";
33
44 // --- the arithmetic -------------------------------------------------------
55
@@ -18,16 +18,78 @@ test("attenuation is full up close, falls off with distance, and never exceeds 1
1818 expect(attenuate(6000, 900, 120)).toBeLessThan(0.2);
1919 });
2020
21 /**
22 * `panOf` by hand. The ear stands at the origin and faces `yaw` degrees — 0 is
23 * +X (east), 90 is +Y (north), the same angle `angleVectors` takes — and the
24 * source is put a long way off so the softening inside `HEAD` is out of the
25 * way. Every row is a direction a player would name, and the number beside it
26 * is which speaker they should hear it in.
27 */
28 test("panOf is -1 on the left, +1 on the right, and 0 ahead, behind and above", () => {
29 const ear: [number, number, number] = [0, 0, 0];
30 const table: { yaw: number; source: [number, number, number]; pan: number; why: string }[] = [
31 { yaw: 90, source: [-1000, 0, 0], pan: -1, why: "facing north, a sound due west is hard left" },
32 { yaw: 90, source: [1000, 0, 0], pan: 1, why: "facing north, a sound due east is hard right" },
33 { yaw: 90, source: [0, 1000, 0], pan: 0, why: "facing north, a sound due north is dead ahead" },
34 { yaw: 90, source: [0, -1000, 0], pan: 0, why: "facing north, a sound due south is behind: centre" },
35 { yaw: 0, source: [0, -1000, 0], pan: 1, why: "facing east, a sound due south is hard right" },
36 { yaw: 0, source: [0, 1000, 0], pan: -1, why: "facing east, a sound due north is hard left" },
37 { yaw: 0, source: [1000, 0, 0], pan: 0, why: "facing east, a sound due east is dead ahead" },
38 { yaw: -90, source: [1000, 0, 0], pan: -1, why: "facing south, a sound due east is hard left" },
39 { yaw: 180, source: [0, 1000, 0], pan: 1, why: "facing west, a sound due north is hard right" },
40 { yaw: 90, source: [0, 0, 1000], pan: 0, why: "straight overhead has no side at all" },
41 { yaw: 45, source: [1000, 1000, 0], pan: 0, why: "dead ahead on a diagonal is still ahead" },
42 ];
43 for (const row of table) expect([row.why, panOf(row.source, ear, row.yaw)])
44 .toEqual([row.why, expect.closeTo(row.pan, 12)]);
45 // Between ahead and hard right it is the sine of the angle off the nose: an
46 // ear turned 30 degrees left of a sound hears it halfway to that speaker.
47 expect(panOf([1000, 0, 0], ear, 30)).toBeCloseTo(0.5, 12);
48 expect(panOf([1000, 0, 0], ear, 60)).toBeCloseTo(Math.sqrt(3) / 2, 12);
49 });
50
51 test("panOf softens inside the head rather than flipping across it", () => {
52 const ear: [number, number, number] = [0, 0, 0];
53 // Facing north, so +X is hard right. At HEAD and beyond it is fully right;
54 // inside it the pan shrinks in proportion to how close the source is, and it
55 // reaches centre exactly as the source reaches the ear.
56 expect(panOf([HEAD, 0, 0], ear, 90)).toBeCloseTo(1, 12);
57 expect(panOf([HEAD * 4, 0, 0], ear, 90)).toBeCloseTo(1, 12);
58 expect(panOf([HEAD / 2, 0, 0], ear, 90)).toBeCloseTo(0.5, 12);
59 expect(panOf([1, 0, 0], ear, 90)).toBeCloseTo(1 / HEAD, 12);
60 expect(panOf([0, 0, 0], ear, 90)).toBe(0); // on top of the ear, and not NaN
61 });
62
63 test("panOf never leaves -1..1, and nonsense in is centre out", () => {
64 for (let yaw = -720; yaw <= 720; yaw += 7) {
65 for (const source of [[300, 0, 0], [-40, 900, 12], [0, 0, -5], [7, -7, 7]] as [number, number, number][]) {
66 const pan = panOf(source, [0, 0, 0], yaw);
67 expect(pan).toBeGreaterThanOrEqual(-1);
68 expect(pan).toBeLessThanOrEqual(1);
69 }
70 }
71 expect(panOf([NaN, 0, 0], [0, 0, 0], 90)).toBe(0);
72 expect(panOf([300, 0, 0], [0, 0, 0], NaN)).toBe(0);
73 expect(panOf([Infinity, 0, 0], [0, 0, 0], 90)).toBe(0);
74 });
75
2176 // --- the wiring, against a context that only writes down what it was asked --
2277
2378 type Event = { kind: string; at: number; value: number; time: number };
2479
25 /** The smallest thing that answers like an AudioContext, and remembers. */
26 function fakeContext(): {
27 Ctor: Ctor; log: Event[]; buffers: Float32Array[]; contexts: number; resumes: number;
80 /**
81 * The smallest thing that answers like an AudioContext, and remembers.
82 * `panner: false` is a context without `createStereoPanner` — the oldest
83 * Safari, and every double that was written before this repository could pan.
84 */
85 function fakeContext(options: { panner?: boolean } = {}): {
86 Ctor: Ctor; log: Event[]; buffers: Float32Array[]; panners: { value: number }[];
87 contexts: number; resumes: number;
2888 } {
2989 const log: Event[] = [];
3090 const buffers: Float32Array[] = [];
91 /** The `pan` of every `StereoPannerNode` this context handed out, in order. */
92 const panners: { value: number }[] = [];
3193 const state = { contexts: 0, resumes: 0 };
3294 const param = (kind: string) => ({
3395 value: 0,
@@ -37,6 +99,7 @@ function fakeContext(): {
3799 });
38100 const node = (kind: string) => ({
39101 kind,
102 pan: param(`${kind}.pan`),
40103 frequency: param(`${kind}.frequency`),
41104 playbackRate: param(`${kind}.rate`),
42105 gain: param(`${kind}.gain`),
@@ -72,10 +135,18 @@ function fakeContext(): {
72135 createGain() { return node("gain"); }
73136 createOscillator() { return node("osc"); }
74137 }
138 if (options.panner !== false) {
139 (Fake.prototype as { createStereoPanner?: () => unknown }).createStereoPanner = () => {
140 const made = node("panner");
141 panners.push(made.pan);
142 return made;
143 };
144 }
75145 return {
76146 Ctor: Fake as unknown as Ctor,
77147 log,
78148 buffers,
149 panners,
79150 get contexts() { return state.contexts; },
80151 get resumes() { return state.resumes; },
81152 };
@@ -148,3 +219,53 @@ test("a muted device builds no nodes at all", () => {
148219 device.tone({ frequency: 440, gain: 0.06, length: 0.09 });
149220 expect(fake.log.length).toBeGreaterThan(0);
150221 });
222
223 // --- panning, and the browser that cannot ---------------------------------
224
225 test("a voice with a pan goes through one panner per voice, set to the pan asked", () => {
226 const fake = fakeContext();
227 const device = createSoundDevice(fake.Ctor);
228 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2, pan: -0.75 });
229 device.tone({ frequency: 440, gain: 0.06, length: 0.09, pan: 0.5 });
230 expect(fake.panners.map((p) => p.value)).toEqual([-0.75, 0.5]);
231 // One node each, and it is what the envelope now ends on: the gain connects
232 // to the panner and the panner to the destination, so there are two hops out
233 // of the envelope's end where there used to be one.
234 expect(fake.log.filter((e) => e.kind === "panner.connect").length).toBe(2);
235 });
236
237 test("a voice with no pan builds no panner and is wired exactly as before", () => {
238 const fake = fakeContext();
239 const device = createSoundDevice(fake.Ctor);
240 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
241 device.tone({ frequency: 440, gain: 0.06, length: 0.09 });
242 expect(fake.panners).toEqual([]);
243 expect(fake.log.some((e) => e.kind === "panner.connect")).toBe(false);
244 });
245
246 test("a pan that is NaN or out of range is clamped before it reaches a node", () => {
247 const fake = fakeContext();
248 const device = createSoundDevice(fake.Ctor);
249 device.tone({ frequency: 440, gain: 0.06, length: 0.09, pan: NaN });
250 device.tone({ frequency: 440, gain: 0.06, length: 0.09, pan: 9 });
251 device.tone({ frequency: 440, gain: 0.06, length: 0.09, pan: -9 });
252 device.tone({ frequency: 440, gain: 0.06, length: 0.09, pan: Infinity });
253 expect(fake.panners.map((p) => p.value)).toEqual([0, 1, -1, 0]);
254 });
255
256 /**
257 * The oldest Safari, and every hand-written double in this repository that was
258 * written before the device could pan: `createStereoPanner` is simply missing.
259 * A sound asked to come from the left comes from the middle, which is the game
260 * as it was — and emphatically not a thrown frame.
261 */
262 test("a context with no createStereoPanner plays the sound centred instead of throwing", () => {
263 const fake = fakeContext({ panner: false });
264 const device = createSoundDevice(fake.Ctor);
265 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2, pan: -1 });
266 device.tone({ frequency: 440, gain: 0.06, length: 0.09, pan: 1 });
267 expect(fake.panners).toEqual([]);
268 // Both voices still started and stopped: the sound was made, not skipped.
269 expect(find(fake.log, "source.start")).toBeDefined();
270 expect(find(fake.log, "osc.start")).toBeDefined();
271 });
src/engine/sound.ts +68 −5
@@ -2,7 +2,14 @@
22 * Sound, synthesised. Two voices are enough for a game: a burst of noise
33 * through a resonant filter, which is anything that hits or explodes, and a
44 * tone that can slide, which is anything that beeps or thumps. Both are shaped
5 * by one decaying envelope and go straight to the destination.
5 * by one decaying envelope, optionally placed left or right of the ear, and
6 * then go to the destination.
7 *
8 * The device is positional in exactly one dimension: a voice may carry a `pan`,
9 * −1 hard left to +1 hard right, and `panOf` is the arithmetic that works one
10 * out from where a sound is and which way a listener faces. Front and back are
11 * out of scope on purpose — two speakers cannot say the difference, and the
12 * filter that pretends otherwise is a whole head model this game does not want.
613 *
714 * The `AudioContext` is opened on the first sound, not on load, because a
815 * browser will not start one until the person has clicked something — and
@@ -10,6 +17,8 @@
1017 * constructor is a parameter so a test can hand in its own.
1118 */
1219
20 import type { Vec3 } from "./math.ts";
21
1322 export type Ctor = new () => AudioContext;
1423
1524 /** A burst of band-passed noise: a gunshot, an impact, a footstep. */
@@ -23,6 +32,8 @@ export type Burst = {
2332 q?: number;
2433 /** Playback rate of the noise buffer; vary it so repeats do not phase. */
2534 rate?: number;
35 /** Where between the speakers, −1 left to +1 right. Omitted is dead centre. */
36 pan?: number;
2637 };
2738
2839 /** A tone, optionally sliding to a second frequency across its length. */
@@ -36,6 +47,8 @@ export type Tone = {
3647 floor?: number;
3748 /** Extra seconds to hold the node open after the envelope has landed. */
3849 tail?: number;
50 /** Where between the speakers, −1 left to +1 right. Omitted is dead centre. */
51 pan?: number;
3952 };
4053
4154 export type SoundDevice = {
@@ -55,6 +68,39 @@ export type SoundDevice = {
5568 export const attenuate = (distance: number, reference: number, near: number): number =>
5669 Math.min(1, reference / Math.max(near, Math.max(0, distance)));
5770
71 /**
72 * Inside this many world units a sound is treated as being on top of the ear
73 * and is softened towards the centre in proportion: at 32 units to the right of
74 * a 64-unit head it is half right, at 64 and beyond it is hard right. Without
75 * it a body walking through you flips the pan from one speaker to the other in
76 * a single frame, which is the one thing stereo does that a player notices as a
77 * bug rather than as a direction. 64 Source units is about 1.2 m — a stride.
78 */
79 export const HEAD = 64;
80
81 /**
82 * Which side a sound is on, from the ear's point of view: −1 hard left, +1 hard
83 * right, and 0 dead ahead, dead behind, straight above or on top of the ear.
84 * `yaw` is the heading the ear faces in degrees, 0 = +X, the same angle
85 * `angleVectors` takes, and the answer is the component of the direction to
86 * `source` along the ear's right — `right = (sin yaw, −cos yaw, 0)` — over the
87 * distance, or over `HEAD` when that is larger.
88 *
89 * Front and back are not in the answer and cannot be: two speakers say left and
90 * right, so a sound ahead and the same sound behind are both centre.
91 */
92 export function panOf(source: Vec3, ear: Vec3, yaw: number): number {
93 const dx = source[0] - ear[0], dy = source[1] - ear[1], dz = source[2] - ear[2];
94 const far = Math.hypot(dx, dy, dz);
95 if (!Number.isFinite(far) || !Number.isFinite(yaw)) return 0;
96 const right = dx * Math.sin((yaw * Math.PI) / 180) - dy * Math.cos((yaw * Math.PI) / 180);
97 return right / Math.max(far, HEAD);
98 }
99
100 /** A pan fit to hand to a node: finite, and inside −1..1. Anything else is centre. */
101 const panValue = (pan: number): number =>
102 Number.isFinite(pan) ? Math.min(1, Math.max(-1, pan)) : 0;
103
58104 const NOISE_SECONDS = 0.5;
59105
60106 const defaultCtor = (): Ctor | undefined => {
@@ -87,11 +133,28 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound
87133 return node;
88134 };
89135
136 /**
137 * What a voice ends on: one `StereoPannerNode` into the destination when a
138 * pan was asked for, and the destination itself when it was not — or when
139 * this browser has no panner to give, which is the oldest Safari and every
140 * hand-written fake context in this repository. A voice with no pan costs no
141 * node, so a game that never places a sound is wired exactly as before.
142 */
143 const sink = (context: AudioContext, pan: number | undefined): AudioNode => {
144 if (pan === undefined) return context.destination;
145 const make = (context as Partial<AudioContext>).createStereoPanner;
146 if (typeof make !== "function") return context.destination;
147 const panner = make.call(context);
148 panner.pan.value = panValue(pan);
149 panner.connect(context.destination);
150 return panner;
151 };
152
90153 const device: SoundDevice = {
91154 muted: false,
92155 resume: () => void ensure(),
93156
94 noiseBurst({ frequency, gain, length, q = 0.9, rate = 1 }) {
157 noiseBurst({ frequency, gain, length, q = 0.9, rate = 1, pan }) {
95158 if (device.muted) return;
96159 const context = ensure();
97160 if (context === null || noise === null) return;
@@ -103,12 +166,12 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound
103166 filter.type = "bandpass";
104167 filter.frequency.value = frequency;
105168 filter.Q.value = q;
106 source.connect(filter).connect(decay(context, now, gain, length, 0.0008)).connect(context.destination);
169 source.connect(filter).connect(decay(context, now, gain, length, 0.0008)).connect(sink(context, pan));
107170 source.start(now);
108171 source.stop(now + length);
109172 },
110173
111 tone({ frequency, gain, length, slideTo, type = "sine", floor = 0.0008, tail = 0 }) {
174 tone({ frequency, gain, length, slideTo, type = "sine", floor = 0.0008, tail = 0, pan }) {
112175 if (device.muted) return;
113176 const context = ensure();
114177 if (context === null) return;
@@ -120,7 +183,7 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound
120183 osc.frequency.setValueAtTime(frequency, now);
121184 osc.frequency.exponentialRampToValueAtTime(slideTo, now + length);
122185 }
123 osc.connect(decay(context, now, gain, length, floor)).connect(context.destination);
186 osc.connect(decay(context, now, gain, length, floor)).connect(sink(context, pan));
124187 osc.start(now);
125188 osc.stop(now + length + tail);
126189 },
src/range/audio.test.ts +6
@@ -36,6 +36,7 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex
3636 kind,
3737 type: "",
3838 buffer: null as unknown,
39 pan: param(`${kind}.pan`),
3940 frequency: param(`${kind}.frequency`),
4041 playbackRate: param(`${kind}.rate`),
4142 gain: param(`${kind}.gain`),
@@ -64,6 +65,11 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex
6465 createBiquadFilter() { return node("filter"); }
6566 createGain() { return node("gain"); }
6667 createOscillator() { return node("osc"); }
68 // A browser has one, so this does too. The range places no sound yet — it
69 // passes no `pan`, and the device builds no panner for a voice without one
70 // — but a double that answers less than the thing it doubles is how a
71 // thrown frame hides until the day somebody adds the argument.
72 createStereoPanner() { return node("panner"); }
6773 }
6874 return {
6975 Ctor: Fake as unknown as Ctor,
src/range/stage.test-helper.ts +6
@@ -118,6 +118,7 @@ export function audioCounter(): FakeAudio {
118118 const made = {
119119 type: "",
120120 buffer: null as unknown,
121 pan: param(() => {}),
121122 frequency: param(() => {}),
122123 playbackRate: param(() => {}),
123124 Q: param(() => {}),
@@ -145,6 +146,11 @@ export function audioCounter(): FakeAudio {
145146 createBiquadFilter() { return node("filter"); }
146147 createGain() { return node("gain"); }
147148 createOscillator() { return node("osc"); }
149 // A browser has one, so this does too. The range places no sound yet — it
150 // passes no `pan`, and the device builds no panner for a voice without one
151 // — but a double that answers less than the thing it doubles is how a
152 // thrown frame hides until the day somebody adds the argument.
153 createStereoPanner() { return node("panner"); }
148154 }
149155 return {
150156 get contexts() { return state.contexts; },
static/app.5e15fd39a1.js added +58

Diff not shown — a line 77,848 characters wide (limit 2,000). +58

View file
static/app.9e515111bc.js deleted −58

Diff not shown — a line 77,687 characters wide (limit 2,000). −58

View file
static/example.fad143299f.js → static/example.4f58463d87.js renamed +1 −1

Diff not shown — a line 9,725 characters wide (limit 2,000). +1 −1

View file
static/range.3eb16f5ea0.js → static/range.5713b7a936.js renamed +5 −5

Diff not shown — a line 18,819 characters wide (limit 2,000). +5 −5

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs