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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/209-a-sound-says-which-side-it-came-from-the → main 1 commit fork
17 files
changed +466
−107
src/client/audio.test.ts +80
| @@ -147,3 +147,83 @@ test("resuming the voice resumes the device", () => { | ||
| 147 | 147 | createAudio(device).resume(); |
| 148 | 148 | expect(device.resumes).toBe(1); |
| 149 | 149 | }); |
| 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 @@ | ||
| 6 | 6 | * |
| 7 | 7 | * Everything the round itself makes — a foot landing, the bomb's beep, the |
| 8 | 8 | * 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 | |
| 11 | 16 | * the numbers below: a sound is at its recipe's full gain inside its earshot |
| 12 | 17 | * and falls as `earshot / distance` past it, so a footstep at 256 units is as |
| 13 | 18 | * loud as a footstep can be and the same one at 1,024 is a quarter of that. |
| 14 | 19 | */ |
| 15 | 20 | |
| 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"; | |
| 17 | 22 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 18 | 23 | import type { Sound } from "./world.ts"; |
| 19 | 24 | |
| 20 | 25 | 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; | |
| 22 | 32 | /** |
| 23 | 33 | * One noise of the round, by name, placed where it happened and heard from |
| 24 | 34 | * `listener`. `own` is true when the body that made it is the body these ears |
| @@ -28,8 +38,12 @@ export type Audio = { | ||
| 28 | 38 | * One path widened by a name rather than a voice per noise: a grenade leaving |
| 29 | 39 | * the hand and a foot landing are the same two nodes with different numbers |
| 30 | 40 | * 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. | |
| 31 | 45 | */ |
| 32 | play(sound: Sound, at: Vec3, listener: Vec3, own?: boolean): void; | |
| 46 | play(sound: Sound, at: Vec3, listener: Vec3, own?: boolean, yaw?: number): void; | |
| 33 | 47 | hit(headshot: boolean): void; |
| 34 | 48 | beep(pitch: number, gain?: number): void; |
| 35 | 49 | resume(): void; |
| @@ -107,75 +121,81 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio { | ||
| 107 | 121 | device.muted = value; |
| 108 | 122 | }, |
| 109 | 123 | resume: () => device.resume(), |
| 110 | shot(weapon, at, listener) { | |
| 124 | shot(weapon, at, listener, yaw = 0) { | |
| 111 | 125 | const tone = shotTone(weapon); |
| 112 | 126 | const volume = loudness(at, listener); |
| 127 | const pan = panOf(at, listener, yaw); | |
| 113 | 128 | device.noiseBurst({ |
| 114 | 129 | frequency: tone.cut, |
| 115 | 130 | gain: 0.35 * volume, |
| 116 | 131 | length: tone.length, |
| 117 | 132 | q: 0.9, |
| 118 | 133 | rate: 1 + Math.random() * 0.15, |
| 134 | pan, | |
| 119 | 135 | }); |
| 120 | 136 | device.tone({ |
| 121 | 137 | frequency: tone.thump, |
| 122 | 138 | slideTo: tone.thump * 0.4, |
| 123 | 139 | gain: 0.3 * volume, |
| 124 | 140 | length: tone.length, |
| 141 | pan, | |
| 125 | 142 | }); |
| 126 | 143 | }, |
| 127 | play(sound, at, listener, own = false) { | |
| 144 | play(sound, at, listener, own = false, yaw = 0) { | |
| 128 | 145 | 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); | |
| 129 | 149 | if (sound === "step") { |
| 130 | 150 | // A foot landing: a scuff of grit through a wide filter with a soft |
| 131 | 151 | // thump under it. The rate is jittered so a body at a dead run is a |
| 132 | 152 | // walk and not one sample played twelve times. |
| 133 | 153 | 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 }); | |
| 136 | 156 | return; |
| 137 | 157 | } |
| 138 | 158 | if (sound === "beep") { |
| 139 | 159 | // The bomb, counting. A square tone and nothing under it: it has to cut |
| 140 | 160 | // through a firefight, and it is the one sound here that repeats. |
| 141 | 161 | 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 }); | |
| 143 | 163 | return; |
| 144 | 164 | } |
| 145 | 165 | if (sound === "plant") { |
| 146 | 166 | // The bomb going down: the clunk of it landing, and a tone that climbs |
| 147 | 167 | // — the round just turned over, and it turned the terrorists' way. |
| 148 | 168 | 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 }); | |
| 151 | 171 | return; |
| 152 | 172 | } |
| 153 | 173 | if (sound === "defuse") { |
| 154 | 174 | // The wire, cut: a bright snip and a tone that falls away under it. It |
| 155 | 175 | // is the plant's noise backwards on purpose — the same event, undone. |
| 156 | 176 | 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 }); | |
| 159 | 179 | return; |
| 160 | 180 | } |
| 161 | 181 | if (sound === "death") { |
| 162 | 182 | // A body hitting the floor: low, wide, short, with a thump that falls |
| 163 | 183 | // away. No cry — the game has no voices, and a fall carries further. |
| 164 | 184 | 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 }); | |
| 167 | 187 | return; |
| 168 | 188 | } |
| 169 | 189 | if (sound === "throw") { |
| 170 | 190 | // 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 }); | |
| 172 | 192 | return; |
| 173 | 193 | } |
| 174 | 194 | if (sound === "bounce") { |
| 175 | 195 | // A hard tap: the shell against concrete, tight and short. |
| 176 | 196 | 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 }); | |
| 179 | 199 | return; |
| 180 | 200 | } |
| 181 | 201 | if (sound === "flash") { |
| @@ -185,15 +205,15 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio { | ||
| 185 | 205 | // ears. It carries as far as the blast does, and it matters over a |
| 186 | 206 | // wider circle than one, because so does the light. |
| 187 | 207 | 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 }); | |
| 190 | 210 | return; |
| 191 | 211 | } |
| 192 | 212 | // The blast: a wide, low body of noise with a thump under it that falls |
| 193 | 213 | // an octave and a half as it goes. |
| 194 | 214 | 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 }); | |
| 197 | 217 | }, |
| 198 | 218 | hit(headshot) { |
| 199 | 219 | 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 | ||
| 209 | 209 | expect(stage.sounds).toContain("sound:step"); |
| 210 | 210 | }); |
| 211 | 211 | |
| 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 | ||
| 212 | 230 | test("one frame of 1/30 s runs two 64 Hz steps and no more, and banks the rest", () => { |
| 213 | 231 | const stage = playing(); |
| 214 | 232 | stage.step(FRAME); |
src/client/main.ts +6 −3
| @@ -747,7 +747,9 @@ export function boot(host: Host): Frame { | ||
| 747 | 747 | |
| 748 | 748 | /** |
| 749 | 749 | * 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: | |
| 751 | 753 | * a round heard from your own corpse is the other half of a broken spectate. |
| 752 | 754 | * Which is also why "your own" footstep is the one `ears` took and not the |
| 753 | 755 | * 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 { | ||
| 757 | 759 | for (let i = 0; i < current.effects.count; i++) { |
| 758 | 760 | const effect = effectAt(current.effects, i); |
| 759 | 761 | 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); | |
| 762 | 765 | else if (effect.kind === "hitmark") audio.hit(effect.headshot); |
| 763 | 766 | } |
| 764 | 767 | lastSound = current.time; |
src/client/stage.test-helper.ts +17 −3
| @@ -139,6 +139,13 @@ export type FakeStage = { | ||
| 139 | 139 | atlases: Uint8Array[]; |
| 140 | 140 | /** Every sound asked for, as `shot:ak47`, `hit`, `beep` or `resume`. */ |
| 141 | 141 | 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[]; | |
| 142 | 149 | store: Map<string, string>; |
| 143 | 150 | /** The match ids handed out, newest last. */ |
| 144 | 151 | ids: string[]; |
| @@ -215,6 +222,7 @@ export function fakeStage( | ||
| 215 | 222 | const replaced: { name: string; mesh: Mesh }[] = []; |
| 216 | 223 | const atlases: Uint8Array[] = []; |
| 217 | 224 | const sounds: string[] = []; |
| 225 | const headings: number[] = []; | |
| 218 | 226 | const store = new Map<string, string>(); |
| 219 | 227 | const ids: string[] = []; |
| 220 | 228 | const copied: string[] = []; |
| @@ -223,11 +231,17 @@ export function fakeStage( | ||
| 223 | 231 | const audio: Audio = { |
| 224 | 232 | muted: false, |
| 225 | 233 | 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 | }, | |
| 227 | 238 | // Every noise the round makes goes through this one, and it is written down |
| 228 | 239 | // with whether the ears that heard it are the ones that made it: a stage |
| 229 | 240 | // 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 | }, | |
| 231 | 245 | hit: (headshot) => void sounds.push(headshot ? "hit:head" : "hit"), |
| 232 | 246 | beep: () => void sounds.push("beep"), |
| 233 | 247 | }; |
| @@ -284,7 +298,7 @@ export function fakeStage( | ||
| 284 | 298 | hud, |
| 285 | 299 | buttons: page.buttons, |
| 286 | 300 | frames: page.frames, |
| 287 | posts, draws, replaced, atlases, sounds, store, ids, copied, | |
| 301 | posts, draws, replaced, atlases, sounds, headings, store, ids, copied, | |
| 288 | 302 | refuseCopy: false, |
| 289 | 303 | by: page.by, |
| 290 | 304 | 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` | ||
| 288 | 288 | the range's, and they share no sound; the example opens no device at all, which |
| 289 | 289 | is why a test can import it in a process with no `AudioContext` in it. |
| 290 | 290 | |
| 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 | ||
| 291 | 313 | ### loop |
| 292 | 314 | |
| 293 | 315 | `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 = [ | ||
| 321 | 321 | "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside", |
| 322 | 322 | "length", "length2d", "lerp", |
| 323 | 323 | "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", | |
| 325 | 325 | "paramCount", "perspective", "pushEffect", "pushLine", "quadMesh", "quantise", "rad", "rayBox", |
| 326 | 326 | "region", "resetEffects", "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", |
| 327 | 327 | "softmax", "stuck", "sub", |
src/engine/guide.test.ts +4 −2
| @@ -61,9 +61,11 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; | ||
| 61 | 61 | * this file ignores without needing to be told about it. |
| 62 | 62 | */ |
| 63 | 63 | const NOT_THE_ENGINES = new Set([ |
| 64 | "AudioContext", "Float32Array", "Float64Array", "Int32Array", "TEXTURE_2D_ARRAY", | |
| 64 | "AudioContext", "Float32Array", "Float64Array", "Int32Array", "StereoPannerNode", | |
| 65 | "TEXTURE_2D_ARRAY", | |
| 65 | 66 | "addEventListener", |
| 66 | "cancelAnimationFrame", "de_forge", "fullscreenElement", "pointerLockElement", | |
| 67 | "cancelAnimationFrame", "createStereoPanner", "de_forge", "fullscreenElement", | |
| 68 | "pointerLockElement", | |
| 67 | 69 | "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv", |
| 68 | 70 | ]); |
| 69 | 71 |
src/engine/index.ts +6 −2
| @@ -43,8 +43,12 @@ export type { Builder, Mesh } from "./mesh.ts"; | ||
| 43 | 43 | export { fbm, hash, noise, paintAtlas, painter } from "./texture.ts"; |
| 44 | 44 | export type { Painter, Shader } from "./texture.ts"; |
| 45 | 45 | |
| 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"; | |
| 48 | 52 | export type { Burst, Ctor, SoundDevice, Tone } from "./sound.ts"; |
| 49 | 53 | |
| 50 | 54 | /** A fixed simulation step with a clamp on a long frame, clock supplied. */ |
src/engine/sound.test.ts +125 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | |
| 4 | 4 | // --- the arithmetic ------------------------------------------------------- |
| 5 | 5 | |
| @@ -18,16 +18,78 @@ test("attenuation is full up close, falls off with distance, and never exceeds 1 | ||
| 18 | 18 | expect(attenuate(6000, 900, 120)).toBeLessThan(0.2); |
| 19 | 19 | }); |
| 20 | 20 | |
| 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 | ||
| 21 | 76 | // --- the wiring, against a context that only writes down what it was asked -- |
| 22 | 77 | |
| 23 | 78 | type Event = { kind: string; at: number; value: number; time: number }; |
| 24 | 79 | |
| 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; | |
| 28 | 88 | } { |
| 29 | 89 | const log: Event[] = []; |
| 30 | 90 | const buffers: Float32Array[] = []; |
| 91 | /** The `pan` of every `StereoPannerNode` this context handed out, in order. */ | |
| 92 | const panners: { value: number }[] = []; | |
| 31 | 93 | const state = { contexts: 0, resumes: 0 }; |
| 32 | 94 | const param = (kind: string) => ({ |
| 33 | 95 | value: 0, |
| @@ -37,6 +99,7 @@ function fakeContext(): { | ||
| 37 | 99 | }); |
| 38 | 100 | const node = (kind: string) => ({ |
| 39 | 101 | kind, |
| 102 | pan: param(`${kind}.pan`), | |
| 40 | 103 | frequency: param(`${kind}.frequency`), |
| 41 | 104 | playbackRate: param(`${kind}.rate`), |
| 42 | 105 | gain: param(`${kind}.gain`), |
| @@ -72,10 +135,18 @@ function fakeContext(): { | ||
| 72 | 135 | createGain() { return node("gain"); } |
| 73 | 136 | createOscillator() { return node("osc"); } |
| 74 | 137 | } |
| 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 | } | |
| 75 | 145 | return { |
| 76 | 146 | Ctor: Fake as unknown as Ctor, |
| 77 | 147 | log, |
| 78 | 148 | buffers, |
| 149 | panners, | |
| 79 | 150 | get contexts() { return state.contexts; }, |
| 80 | 151 | get resumes() { return state.resumes; }, |
| 81 | 152 | }; |
| @@ -148,3 +219,53 @@ test("a muted device builds no nodes at all", () => { | ||
| 148 | 219 | device.tone({ frequency: 440, gain: 0.06, length: 0.09 }); |
| 149 | 220 | expect(fake.log.length).toBeGreaterThan(0); |
| 150 | 221 | }); |
| 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 @@ | ||
| 2 | 2 | * Sound, synthesised. Two voices are enough for a game: a burst of noise |
| 3 | 3 | * through a resonant filter, which is anything that hits or explodes, and a |
| 4 | 4 | * 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. | |
| 6 | 13 | * |
| 7 | 14 | * The `AudioContext` is opened on the first sound, not on load, because a |
| 8 | 15 | * browser will not start one until the person has clicked something — and |
| @@ -10,6 +17,8 @@ | ||
| 10 | 17 | * constructor is a parameter so a test can hand in its own. |
| 11 | 18 | */ |
| 12 | 19 | |
| 20 | import type { Vec3 } from "./math.ts"; | |
| 21 | ||
| 13 | 22 | export type Ctor = new () => AudioContext; |
| 14 | 23 | |
| 15 | 24 | /** A burst of band-passed noise: a gunshot, an impact, a footstep. */ |
| @@ -23,6 +32,8 @@ export type Burst = { | ||
| 23 | 32 | q?: number; |
| 24 | 33 | /** Playback rate of the noise buffer; vary it so repeats do not phase. */ |
| 25 | 34 | rate?: number; |
| 35 | /** Where between the speakers, −1 left to +1 right. Omitted is dead centre. */ | |
| 36 | pan?: number; | |
| 26 | 37 | }; |
| 27 | 38 | |
| 28 | 39 | /** A tone, optionally sliding to a second frequency across its length. */ |
| @@ -36,6 +47,8 @@ export type Tone = { | ||
| 36 | 47 | floor?: number; |
| 37 | 48 | /** Extra seconds to hold the node open after the envelope has landed. */ |
| 38 | 49 | tail?: number; |
| 50 | /** Where between the speakers, −1 left to +1 right. Omitted is dead centre. */ | |
| 51 | pan?: number; | |
| 39 | 52 | }; |
| 40 | 53 | |
| 41 | 54 | export type SoundDevice = { |
| @@ -55,6 +68,39 @@ export type SoundDevice = { | ||
| 55 | 68 | export const attenuate = (distance: number, reference: number, near: number): number => |
| 56 | 69 | Math.min(1, reference / Math.max(near, Math.max(0, distance))); |
| 57 | 70 | |
| 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 | ||
| 58 | 104 | const NOISE_SECONDS = 0.5; |
| 59 | 105 | |
| 60 | 106 | const defaultCtor = (): Ctor | undefined => { |
| @@ -87,11 +133,28 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound | ||
| 87 | 133 | return node; |
| 88 | 134 | }; |
| 89 | 135 | |
| 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 | ||
| 90 | 153 | const device: SoundDevice = { |
| 91 | 154 | muted: false, |
| 92 | 155 | resume: () => void ensure(), |
| 93 | 156 | |
| 94 | noiseBurst({ frequency, gain, length, q = 0.9, rate = 1 }) { | |
| 157 | noiseBurst({ frequency, gain, length, q = 0.9, rate = 1, pan }) { | |
| 95 | 158 | if (device.muted) return; |
| 96 | 159 | const context = ensure(); |
| 97 | 160 | if (context === null || noise === null) return; |
| @@ -103,12 +166,12 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound | ||
| 103 | 166 | filter.type = "bandpass"; |
| 104 | 167 | filter.frequency.value = frequency; |
| 105 | 168 | 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)); | |
| 107 | 170 | source.start(now); |
| 108 | 171 | source.stop(now + length); |
| 109 | 172 | }, |
| 110 | 173 | |
| 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 }) { | |
| 112 | 175 | if (device.muted) return; |
| 113 | 176 | const context = ensure(); |
| 114 | 177 | if (context === null) return; |
| @@ -120,7 +183,7 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound | ||
| 120 | 183 | osc.frequency.setValueAtTime(frequency, now); |
| 121 | 184 | osc.frequency.exponentialRampToValueAtTime(slideTo, now + length); |
| 122 | 185 | } |
| 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)); | |
| 124 | 187 | osc.start(now); |
| 125 | 188 | osc.stop(now + length + tail); |
| 126 | 189 | }, |
src/range/audio.test.ts +6
| @@ -36,6 +36,7 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex | ||
| 36 | 36 | kind, |
| 37 | 37 | type: "", |
| 38 | 38 | buffer: null as unknown, |
| 39 | pan: param(`${kind}.pan`), | |
| 39 | 40 | frequency: param(`${kind}.frequency`), |
| 40 | 41 | playbackRate: param(`${kind}.rate`), |
| 41 | 42 | gain: param(`${kind}.gain`), |
| @@ -64,6 +65,11 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex | ||
| 64 | 65 | createBiquadFilter() { return node("filter"); } |
| 65 | 66 | createGain() { return node("gain"); } |
| 66 | 67 | 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"); } | |
| 67 | 73 | } |
| 68 | 74 | return { |
| 69 | 75 | Ctor: Fake as unknown as Ctor, |
src/range/stage.test-helper.ts +6
| @@ -118,6 +118,7 @@ export function audioCounter(): FakeAudio { | ||
| 118 | 118 | const made = { |
| 119 | 119 | type: "", |
| 120 | 120 | buffer: null as unknown, |
| 121 | pan: param(() => {}), | |
| 121 | 122 | frequency: param(() => {}), |
| 122 | 123 | playbackRate: param(() => {}), |
| 123 | 124 | Q: param(() => {}), |
| @@ -145,6 +146,11 @@ export function audioCounter(): FakeAudio { | ||
| 145 | 146 | createBiquadFilter() { return node("filter"); } |
| 146 | 147 | createGain() { return node("gain"); } |
| 147 | 148 | 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"); } | |
| 148 | 154 | } |
| 149 | 155 | return { |
| 150 | 156 | 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 filestatic/app.9e515111bc.js deleted −58
Diff not shown — a line 77,687 characters wide (limit 2,000). −58
View filestatic/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 filestatic/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
new above
offline