SOURCE / PINNED RELEASE
Made of little things.
Pursuit PIT
- Release
- 565f4981bdc3…
- Author-recorded commit
- 197afdbed44b…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7
Archive hash verified: 3b0485589942fd5c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// All sound is synthesized with Web Audio (napplets have no ambient network access,
// so there are no sample files). Sounds from outside the car go through a "cabin" low-pass
// so they sound muffled from the driver's seat.
import { forwardSpeed, slipSpeed, type Car, type Sim, type SimEvent } from './sim.js';
import type { Gearbox } from './engine.js';
const CYLINDERS = 8;
function smooth(param: AudioParam, value: number, ctx: AudioContext, time = 0.05): void {
param.setTargetAtTime(value, ctx.currentTime, time);
}
/** Where the sound is heard from: the player's car, or the replay camera. */
interface Listener {
x: number;
s: number;
yaw: number;
}
/** Position of a world point relative to the listener: lateral (right +) and distance. */
function relative(player: Listener, x: number, s: number): { lat: number; dist: number } {
const dx = x - player.x, ds = s - player.s;
return { lat: dx * Math.cos(player.yaw) - ds * Math.sin(player.yaw), dist: Math.hypot(dx, ds) };
}
export class Sound {
volume = 0.7;
muted = false;
siren = true;
/**
* Getaway: distance to the nearest police car, whose siren is heard from outside (louder as
* it closes, yelping when it's right on you). Null when no one is chasing you.
*/
sirenDistance: number | null = null;
private stereoOut: GainNode | null = null;
private ctx?: AudioContext;
private master!: GainNode;
private cabin!: BiquadFilterNode;
private noise!: AudioBuffer;
private engine!: { osc: OscillatorNode[]; filter: BiquadFilterNode; gain: GainNode; burble: OscillatorNode; intake: BiquadFilterNode; intakeGain: GainNode };
private road!: { filter: BiquadFilterNode; gain: GainNode };
private wind!: { filter: BiquadFilterNode; gain: GainNode };
private screech!: { tone: OscillatorNode; gain: GainNode };
private scrape!: { gain: GainNode; grind: OscillatorNode };
private rumble!: { osc: OscillatorNode; gain: GainNode };
private traffic!: { filter: BiquadFilterNode; pan: StereoPannerNode; gain: GainNode };
private sirenNodes!: { osc: OscillatorNode; gain: GainNode };
private sirenPhase = 0;
private hornCooldown = new Map<number, number>();
private radioBus!: GainNode;
/** Must be called from a user gesture (browsers block audio until then). */
start(): void {
if (this.ctx) {
void this.ctx.resume();
return;
}
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
this.ctx = ctx;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -14;
comp.ratio.value = 4;
this.master = ctx.createGain();
this.master.gain.value = this.muted ? 0 : this.volume;
this.master.connect(comp).connect(ctx.destination);
this.radioBus = ctx.createGain();
this.radioBus.gain.value = this.muted ? 0 : Math.min(1.6, this.volume * 1.8);
this.radioBus.connect(ctx.destination);
this.cabin = ctx.createBiquadFilter();
this.cabin.type = 'lowpass';
this.cabin.frequency.value = 2600;
this.cabin.connect(this.master);
this.noise = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
const data = this.noise.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
this.buildEngine(ctx);
this.buildLoops(ctx);
}
suspend(): void {
void this.ctx?.suspend();
}
setVolume(volume: number, muted: boolean): void {
this.volume = volume;
this.muted = muted;
this.applyLevels();
}
private applyLevels(): void {
if (!this.ctx) return;
smooth(this.master.gain, this.muted ? 0 : this.volume, this.ctx, 0.12);
// The radio's squelch, static and tones sit louder than the game, on their own bus.
smooth(this.radioBus.gain, this.muted ? 0 : Math.min(1.6, this.volume * 1.8), this.ctx, 0.05);
}
private noiseSource(ctx: AudioContext): AudioBufferSourceNode {
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
src.loopStart = Math.random();
src.start(0, Math.random() * 1.5);
return src;
}
private buildEngine(ctx: AudioContext): void {
// A V8 fires 4 times per crank revolution. Harmonics through soft clipping and a
// load-dependent low-pass give the growl; a slow beat between banks gives the burble.
const mix = ctx.createGain();
mix.gain.value = 0.5;
const osc = ([['sawtooth', 1, 0.6], ['square', 0.5, 0.35], ['sawtooth', 2, 0.12], ['triangle', 0.25, 0.5]] as const).map(([type, , level]) => {
const o = ctx.createOscillator();
o.type = type;
const g = ctx.createGain();
g.gain.value = level;
o.connect(g).connect(mix);
o.start();
return o;
});
osc[2].detune.value = 9;
const shaper = ctx.createWaveShaper();
const curve = new Float32Array(1024);
for (let i = 0; i < curve.length; i++) {
const x = (i / (curve.length - 1)) * 2 - 1;
curve[i] = Math.tanh(x * 2.6);
}
shaper.curve = curve;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.Q.value = 2.5;
const am = ctx.createGain();
am.gain.value = 0.75;
const burble = ctx.createOscillator();
burble.type = 'sine';
const burbleDepth = ctx.createGain();
burbleDepth.gain.value = 0.25;
burble.connect(burbleDepth).connect(am.gain);
burble.start();
const gain = ctx.createGain();
gain.gain.value = 0;
mix.connect(shaper).connect(filter).connect(am).connect(gain).connect(this.master);
const intake = ctx.createBiquadFilter();
intake.type = 'bandpass';
intake.Q.value = 1.2;
const intakeGain = ctx.createGain();
intakeGain.gain.value = 0;
this.noiseSource(ctx).connect(intake).connect(intakeGain).connect(this.master);
this.engine = { osc, filter, gain, burble, intake, intakeGain };
}
private buildLoops(ctx: AudioContext): void {
const chain = (type: BiquadFilterType, freq: number, q: number, dest: AudioNode) => {
const filter = ctx.createBiquadFilter();
filter.type = type;
filter.frequency.value = freq;
filter.Q.value = q;
const gain = ctx.createGain();
gain.gain.value = 0;
this.noiseSource(ctx).connect(filter).connect(gain).connect(dest);
return { filter, gain };
};
// Tyre roar on asphalt: low, broadband, rising with speed.
this.road = chain('lowpass', 300, 0.7, this.master);
// Wind rush around the A-pillars.
this.wind = chain('bandpass', 900, 0.5, this.cabin);
// Tyre squeal: narrow-band noise plus a wavering tone.
const sq = chain('bandpass', 1500, 9, this.master);
const tone = ctx.createOscillator();
tone.type = 'triangle';
tone.frequency.value = 1050;
const vib = ctx.createOscillator();
vib.frequency.value = 11;
const vibDepth = ctx.createGain();
vibDepth.gain.value = 45;
vib.connect(vibDepth).connect(tone.frequency);
const toneGain = ctx.createGain();
toneGain.gain.value = 0.25;
tone.connect(toneGain).connect(sq.gain);
tone.start();
vib.start();
this.screech = { tone, gain: sq.gain };
// Metal on guardrail: bright scrape noise plus a low grinding buzz.
const sc = chain('bandpass', 2800, 2.5, this.master);
const grind = ctx.createOscillator();
grind.type = 'sawtooth';
grind.frequency.value = 140;
const grindGain = ctx.createGain();
grindGain.gain.value = 0.3;
grind.connect(grindGain).connect(sc.gain);
grind.start();
this.scrape = { gain: sc.gain, grind };
// Rumble strip: tyres drumming over grooves every ~30 cm.
const rOsc = ctx.createOscillator();
rOsc.type = 'square';
const rFilter = ctx.createBiquadFilter();
rFilter.type = 'lowpass';
rFilter.frequency.value = 380;
const rGain = ctx.createGain();
rGain.gain.value = 0;
rOsc.connect(rFilter).connect(rGain).connect(this.master);
rOsc.start();
this.rumble = { osc: rOsc, gain: rGain };
// Nearby traffic: tyre and wind noise from the cars around you, panned to their side.
const tFilter = ctx.createBiquadFilter();
tFilter.type = 'bandpass';
tFilter.Q.value = 0.8;
tFilter.frequency.value = 500;
const tPan = ctx.createStereoPanner();
const tGain = ctx.createGain();
tGain.gain.value = 0;
this.noiseSource(ctx).connect(tFilter).connect(tPan).connect(tGain).connect(this.cabin);
this.traffic = { filter: tFilter, pan: tPan, gain: tGain };
// Roof siren: a wail, switching to a fast yelp when the suspect is close.
const sOsc = ctx.createOscillator();
sOsc.type = 'sawtooth';
const sFilter = ctx.createBiquadFilter();
sFilter.type = 'lowpass';
sFilter.frequency.value = 2400;
const sGain = ctx.createGain();
sGain.gain.value = 0;
sOsc.connect(sFilter).connect(sGain).connect(this.cabin);
sOsc.start();
this.sirenNodes = { osc: sOsc, gain: sGain };
}
update(sim: Sim, gearbox: Gearbox, dt: number, active: boolean): void {
const ctx = this.ctx;
if (!ctx) return;
const p = sim.player;
const speed = Math.abs(forwardSpeed(p));
// Engine
const fire = (gearbox.rpm / 60) * (CYLINDERS / 2);
const mults = [1, 0.5, 2, 0.25];
this.engine.osc.forEach((o, i) => smooth(o.frequency, fire * mults[i], ctx, 0.03));
smooth(this.engine.burble.frequency, fire / 8, ctx, 0.05);
smooth(this.engine.filter.frequency, 220 + gearbox.load * 1600 + gearbox.rpm * 0.25, ctx, 0.04);
smooth(this.engine.gain.gain, 0.16 + gearbox.load * 0.22, ctx, 0.05);
smooth(this.engine.intake.frequency, 300 + gearbox.rpm * 0.35, ctx, 0.05);
smooth(this.engine.intakeGain.gain, gearbox.load * 0.05 * (gearbox.rpm / 6000), ctx, 0.05);
// Road and wind
smooth(this.road.filter.frequency, 140 + speed * 14, ctx);
smooth(this.road.gain.gain, Math.min(0.5, speed / 55) * 0.55, ctx);
smooth(this.wind.filter.frequency, 500 + speed * 22, ctx);
smooth(this.wind.gain.gain, Math.min(1, (speed / 55) ** 2) * 0.35, ctx);
// Squeal from the player's sideways slip, plus any car sliding nearby.
let squeal = Math.min(1, Math.max(0, Math.abs(slipSpeed(p)) - 1.2) / 4) * Math.min(1, speed / 6);
for (const car of sim.cars) {
if (car === p || (car.slide <= 0 && !car.wrecked)) continue;
const moving = Math.hypot(car.vx, car.vs);
if (moving < 3) continue;
const { dist } = relative(p, car.x, car.s);
squeal = Math.max(squeal, Math.min(1, Math.abs(slipSpeed(car)) / 5) * (12 / (12 + dist)));
}
smooth(this.screech.gain.gain, squeal * 0.35, ctx, 0.04);
smooth(this.screech.tone.frequency, 950 + squeal * 250, ctx, 0.1);
smooth(this.scrape.gain.gain, Math.min(1, sim.scrape / 12) * 0.5, ctx, 0.03);
smooth(this.scrape.grind.frequency, 90 + sim.scrape * 6, ctx, 0.05);
smooth(this.rumble.gain.gain, sim.rumble && speed > 3 ? 0.22 : 0, ctx, 0.02);
smooth(this.rumble.osc.frequency, Math.max(20, speed / 0.3), ctx, 0.03);
// Traffic around the player
let loud = 0, panSum = 0, closing = 0;
for (const car of sim.cars) {
if (car.kind === 'player') continue;
const { lat, dist } = relative(p, car.x, car.s);
if (dist > 40) continue;
const own = Math.hypot(car.vx, car.vs);
const rel = Math.hypot(car.vx - p.vx, car.vs - p.vs);
const l = (own * 0.4 + rel) / (1 + (dist * dist) / 20);
loud += l;
panSum += Math.max(-1, Math.min(1, lat / 4)) * l;
closing = Math.max(closing, rel);
}
smooth(this.traffic.gain.gain, Math.min(0.5, loud * 0.03), ctx, 0.08);
smooth(this.traffic.pan.pan, loud > 0 ? panSum / loud : 0, ctx, 0.08);
smooth(this.traffic.filter.frequency, 350 + closing * 25, ctx, 0.08);
// Siren
const chased = this.sirenDistance !== null;
const d = this.sirenDistance ?? 0;
const on = active && (this.siren || (chased && d < 450));
const yelp = on && (chased ? d < 45 : sim.gap < 45 && sim.gap > -20);
this.sirenPhase += dt / (yelp ? 0.32 : 4.6);
const sweep = 0.5 - 0.5 * Math.cos(this.sirenPhase * Math.PI * 2);
smooth(this.sirenNodes.osc.frequency, 650 + sweep * 850, ctx, 0.02);
const level = chased ? 0.1 * Math.max(0, 1 - d / 450) ** 1.5 : 0.07;
smooth(this.sirenNodes.gain.gain, on ? level : 0, ctx, 0.1);
for (const [id, t] of this.hornCooldown) {
if (t - dt <= 0) this.hornCooldown.delete(id);
else this.hornCooldown.set(id, t - dt);
}
}
/** The car stereo's output: through the cabin, like the rest of the inside sounds. */
stereo(): { ctx: AudioContext; out: AudioNode } | null {
const ctx = this.ctx;
if (!ctx) return null;
if (!this.stereoOut) {
this.stereoOut = ctx.createGain();
this.stereoOut.gain.value = 1.3; // turned up: it has to carry over the engine and wind
const speakers = ctx.createBiquadFilter();
speakers.type = 'lowpass';
speakers.frequency.value = 7000;
this.stereoOut.connect(speakers).connect(this.master);
}
return { ctx, out: this.stereoOut };
}
/** Mute the continuous sounds (engine, road, siren...) while a replay plays. */
silence(): void {
const ctx = this.ctx;
if (!ctx) return;
for (const g of [this.engine.gain, this.engine.intakeGain, this.road.gain, this.wind.gain, this.screech.gain,
this.scrape.gain, this.rumble.gain, this.traffic.gain, this.sirenNodes.gain]) smooth(g.gain, 0, ctx, 0.15);
}
/** One-shot sounds for collisions; traffic you hit sometimes leans on the horn. */
event(sim: { player: Listener }, e: SimEvent): void {
const ctx = this.ctx;
if (!ctx) return;
if (e.type === 'damage' || e.type === 'birdsWarn' || e.type === 'birds') return; // the collision itself already made the sound
if (e.type === 'bird') {
// A soft thump, loud only when it's your own car.
const { lat, dist } = relative(sim.player, e.x, e.s);
this.impact(2.5, Math.max(-1, Math.min(1, lat / 3)), 1 / (1 + dist / 6), false);
return;
}
const { lat, dist } = relative(sim.player, e.x, e.s);
const pan = Math.max(-1, Math.min(1, lat / 3));
const near = 1 / (1 + dist / 15);
if (e.type === 'explode') {
this.explosion(pan, 1 / (1 + dist / 40));
return;
}
if (e.type === 'wall') this.impact(e.strength * 0.7, pan, near, true);
else if (e.type === 'crash') this.impact(Math.max(6, e.strength * 0.5), pan, near, false);
else this.impact(e.strength, pan, near, false);
const car = e.other;
if (car && car.kind === 'traffic' && !car.wrecked && Math.random() < 0.75) {
setTimeout(() => this.horn(sim, car), 250 + Math.random() * 400);
}
}
/** Called when the player cuts close in front of traffic. */
nearMiss(sim: Sim, car: Car): void {
if (Math.random() < 0.6) this.horn(sim, car);
}
private horn(sim: { player: Listener }, car: Car): void {
const ctx = this.ctx;
if (!ctx || this.hornCooldown.has(car.id)) return;
this.hornCooldown.set(car.id, 4);
const { lat, dist } = relative(sim.player, car.x, car.s);
const t = ctx.currentTime;
const length = 0.25 + Math.random() * (Math.random() < 0.4 ? 1.2 : 0.3);
const pitch = 0.85 + Math.random() * 0.35; // every car's horn is a bit different
const out = ctx.createGain();
out.gain.setValueAtTime(0, t);
out.gain.linearRampToValueAtTime(0.16 / (1 + dist / 12), t + 0.02);
out.gain.setValueAtTime(0.16 / (1 + dist / 12), t + length);
out.gain.linearRampToValueAtTime(0, t + length + 0.05);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 1800;
const pan = ctx.createStereoPanner();
pan.pan.value = Math.max(-1, Math.min(1, lat / 3));
for (const f of [415, 520]) {
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = f * pitch;
o.connect(filter);
o.start(t);
o.stop(t + length + 0.1);
}
filter.connect(out).connect(pan).connect(this.cabin);
}
/** A fuel-tank explosion: deep boom, a long rumbling tail, and crackling debris. */
private explosion(pan: number, near: number): void {
const ctx = this.ctx!;
const t = ctx.currentTime;
const panner = ctx.createStereoPanner();
panner.pan.value = pan * 0.6;
panner.connect(this.master);
const boom = ctx.createOscillator();
boom.frequency.setValueAtTime(70, t);
boom.frequency.exponentialRampToValueAtTime(22, t + 1.2);
const bg = ctx.createGain();
bg.gain.setValueAtTime(near * 1.2, t);
bg.gain.exponentialRampToValueAtTime(0.001, t + 1.4);
boom.connect(bg).connect(panner);
boom.start(t);
boom.stop(t + 1.5);
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
const low = ctx.createBiquadFilter();
low.type = 'lowpass';
low.frequency.setValueAtTime(1800, t);
low.frequency.exponentialRampToValueAtTime(120, t + 2.5);
const ng = ctx.createGain();
ng.gain.setValueAtTime(near * 1.0, t);
ng.gain.exponentialRampToValueAtTime(0.001, t + 3);
src.connect(low).connect(ng).connect(panner);
src.start(t, Math.random());
src.stop(t + 3.1);
for (let i = 0; i < 24; i++) {
const at = t + 0.1 + Math.random() * 1.8;
const c = ctx.createBufferSource();
c.buffer = this.noise;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 800 + Math.random() * 4000;
bp.Q.value = 3;
const cg = ctx.createGain();
cg.gain.setValueAtTime(near * (0.1 + Math.random() * 0.2), at);
cg.gain.exponentialRampToValueAtTime(0.001, at + 0.03 + Math.random() * 0.08);
c.connect(bp).connect(cg).connect(panner);
c.start(at, Math.random());
c.stop(at + 0.15);
}
}
// ---- police radio: the voice itself comes from speech synthesis (see radio.ts); these are
// the sounds around it: squelch, carrier hiss, the end-of-transmission beep and alert tones.
private radioHiss?: GainNode;
/** Radio static under a transmission. */
radioCarrier(on: boolean): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.radioHiss) {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 1900;
bp.Q.value = 0.9;
this.radioHiss = ctx.createGain();
this.radioHiss.gain.value = 0;
this.noiseSource(ctx).connect(bp).connect(this.radioHiss).connect(this.radioBus);
}
smooth(this.radioHiss.gain, on ? 0.06 : 0, ctx, 0.02);
}
/** Squelch opening (key up) or closing (the "kssht" tail plus a short beep). */
radioSquelch(open: boolean): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const src = ctx.createBufferSource();
src.buffer = this.noise;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 2200;
bp.Q.value = 0.7;
const g = ctx.createGain();
const length = open ? 0.06 : 0.18;
g.gain.setValueAtTime(0.22, t);
g.gain.exponentialRampToValueAtTime(0.001, t + length);
src.connect(bp).connect(g).connect(this.radioBus);
src.start(t, Math.random());
src.stop(t + length + 0.02);
if (!open) this.tone(1050, t, 0.05, 0.08);
}
/**
* Play recorded voice clips back to back, `delay` seconds from now, the way a transmission
* really sounds: the speaker's surroundings leak into their mic (`scene`: a pursuit car
* with its siren, a car without, the helicopter, the dispatch room), a breath before they
* start, the radio's automatic gain pumping it all up, then a narrow, slightly overdriven
* band. Each transmission is a touch faster or slower and higher or lower, like a person.
*/
radioVoice(clips: Float32Array[], rate: number, delay: number, scene: 'siren' | 'car' | 'heli' | 'room'): { duration: number; stop: () => void } | null {
const ctx = this.ctx;
if (!ctx || !clips.length) return null;
const mic = ctx.createGain(); // everything the mic picks up
const agc = ctx.createDynamicsCompressor();
agc.threshold.value = -32;
agc.knee.value = 6;
agc.ratio.value = 8;
agc.attack.value = 0.004;
agc.release.value = 0.18;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 300;
const presence = ctx.createBiquadFilter();
presence.type = 'peaking';
presence.frequency.value = 1700;
presence.Q.value = 0.9;
presence.gain.value = 4;
const drive = ctx.createWaveShaper();
const curve = new Float32Array(1024);
for (let i = 0; i < curve.length; i++) curve[i] = Math.tanh(((i / 1023) * 2 - 1) * 1.5) / Math.tanh(1.5);
drive.curve = curve;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 3000;
const out = ctx.createGain();
out.gain.value = 1.1;
mic.connect(agc).connect(hp).connect(presence).connect(drive).connect(lp).connect(out).connect(this.radioBus);
const nodes: AudioScheduledSourceNode[] = [];
const t0 = ctx.currentTime + delay;
// A breath in before speaking, most of the time.
let at = t0;
if (Math.random() < 0.6) {
const breath = ctx.createBufferSource();
breath.buffer = this.noise;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 1300;
bp.Q.value = 0.6;
const g = ctx.createGain();
g.gain.setValueAtTime(0, at);
g.gain.linearRampToValueAtTime(0.05, at + 0.15);
g.gain.linearRampToValueAtTime(0, at + 0.32);
breath.connect(bp).connect(g).connect(mic);
breath.start(at, Math.random());
breath.stop(at + 0.35);
nodes.push(breath);
at += 0.3;
}
// People never say two things at exactly the same pace and pitch.
const pace = 0.97 + Math.random() * 0.06;
for (const samples of clips) {
const buffer = ctx.createBuffer(1, samples.length, rate);
buffer.copyToChannel(samples as Float32Array<ArrayBuffer>, 0);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.playbackRate.value = pace;
src.connect(mic);
src.start(at);
nodes.push(src);
at += buffer.duration / pace + 0.05 + Math.random() * 0.08;
}
const end = at + 0.1;
// What's around the speaker, low under the voice.
// A sound in the speaker's surroundings: fades in and out with the transmission.
const surround = (level: number) => {
const g = ctx.createGain();
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(level, t0 + 0.05);
g.gain.setValueAtTime(level, end - 0.05);
g.gain.linearRampToValueAtTime(0, end);
g.connect(mic);
return g;
};
const run = (...sources: AudioScheduledSourceNode[]) => {
for (const src of sources) {
src.start(t0);
src.stop(end + 0.05);
nodes.push(src);
}
};
const noise = (type: BiquadFilterType, freq: number, q: number) => {
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
const f = ctx.createBiquadFilter();
f.type = type;
f.frequency.value = freq;
f.Q.value = q;
src.connect(f);
run(src);
return f;
};
if (scene === 'siren' || scene === 'car') {
// Engine and road roar inside a patrol car at speed.
noise('lowpass', 260, 0.7).connect(surround(0.22));
}
if (scene === 'siren') {
// Their own siren wailing outside, muffled by the car body.
const siren = ctx.createOscillator();
siren.type = 'sawtooth';
siren.frequency.value = 1080;
const sweep = ctx.createOscillator();
sweep.frequency.value = 1 / 4.6;
const depth = ctx.createGain();
depth.gain.value = 420;
sweep.connect(depth).connect(siren.frequency);
const muffle = ctx.createBiquadFilter();
muffle.type = 'lowpass';
muffle.frequency.value = 1600;
siren.connect(muffle).connect(surround(0.035));
run(siren, sweep);
}
if (scene === 'heli') {
// Rotor blade slap (about 18 thumps a second) and turbine whine.
const chop = ctx.createGain();
chop.gain.value = 0.5;
const lfo = ctx.createOscillator();
lfo.type = 'square';
lfo.frequency.value = 17.5;
const lfoDepth = ctx.createGain();
lfoDepth.gain.value = 0.45;
lfo.connect(lfoDepth).connect(chop.gain);
noise('lowpass', 420, 0.8).connect(chop).connect(surround(0.5));
const whine = ctx.createOscillator();
whine.frequency.value = 1850;
whine.connect(surround(0.008));
run(lfo, whine);
}
if (scene === 'room') {
// The dispatch room: a faint hum of air handling.
noise('bandpass', 500, 0.5).connect(surround(0.03));
}
return {
duration: end - t0,
stop: () => {
for (const node of nodes) {
try {
node.stop();
} catch {
// Already finished.
}
}
out.disconnect();
},
};
}
/** Dispatch's three-tone alert before priority traffic. */
radioAlert(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
for (let i = 0; i < 3; i++) this.tone(1000, t + i * 0.28, 0.18, 0.09);
}
private tone(freq: number, at: number, length: number, level: number): void {
const ctx = this.ctx!;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = freq;
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = freq;
const g = ctx.createGain();
g.gain.setValueAtTime(0, at);
g.gain.linearRampToValueAtTime(level, at + 0.005);
g.gain.setValueAtTime(level, at + length);
g.gain.linearRampToValueAtTime(0, at + length + 0.01);
o.connect(f).connect(g).connect(this.radioBus);
o.start(at);
o.stop(at + length + 0.02);
}
private impact(strength: number, pan: number, near: number, scrape: boolean): void {
const ctx = this.ctx!;
const t = ctx.currentTime;
const level = Math.min(1, strength / 12) * near;
if (level < 0.02) return;
const panner = ctx.createStereoPanner();
panner.pan.value = pan;
panner.connect(this.master);
// Body thump: a falling low sine.
const thump = ctx.createOscillator();
thump.frequency.setValueAtTime(110, t);
thump.frequency.exponentialRampToValueAtTime(38, t + 0.25);
const tg = ctx.createGain();
tg.gain.setValueAtTime(level * 0.9, t);
tg.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
thump.connect(tg).connect(panner);
thump.start(t);
thump.stop(t + 0.4);
// Crumpling sheet metal: noise through a few ringing resonances.
const src = ctx.createBufferSource();
src.buffer = this.noise;
const decay = 0.25 + Math.min(0.7, strength * 0.05);
const ng = ctx.createGain();
ng.gain.setValueAtTime(level * 0.8, t);
ng.gain.exponentialRampToValueAtTime(0.001, t + decay);
for (let i = 0; i < 4; i++) {
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = (scrape ? 1500 : 350) + Math.random() * 2600;
bp.Q.value = 6 + Math.random() * 10;
src.connect(bp).connect(ng);
}
const body = ctx.createBiquadFilter();
body.type = 'lowpass';
body.frequency.value = 900;
src.connect(body).connect(ng);
ng.connect(panner);
src.start(t, Math.random());
src.stop(t + decay + 0.05);
// Hard hits break glass: a scatter of short, bright ticks.
if (strength > 7 && !scrape) {
for (let i = 0; i < 14; i++) {
const at = t + 0.03 + Math.random() * 0.5;
const g = ctx.createBufferSource();
g.buffer = this.noise;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 4500 + Math.random() * 4000;
const gg = ctx.createGain();
gg.gain.setValueAtTime(level * (0.15 + Math.random() * 0.25), at);
gg.gain.exponentialRampToValueAtTime(0.001, at + 0.02 + Math.random() * 0.05);
g.connect(hp).connect(gg).connect(panner);
g.start(at, Math.random());
g.stop(at + 0.1);
}
}
}
}
