Phase 7: sound, synthesised from the simulation

No audio files. The build stays self-contained, so the engine is detuned
oscillators, tyres and gunfire are shaped noise, ambience is a filtered drone.
The constraint pays: sound generated from the sim can carry the sim's state
instead of decorating it.

The engine has gears, so the note climbs and drops rather than tracking the
speedometer forever. A worn engine sounds wrong rather than merely slow —
roughness rises and the top of the rev range falls away as condition drops, so
decline is audible as well as felt. Bald tyres squeal sooner than good ones, off
the lateral slip the physics actually reports. Gunfire falls off with the square
of distance and pans as the car turns, so a firefight is a direction. Territory
has a tone: the drone drops and thickens past the line, matching the palette.

Split so it can be tested: audio/mix.ts holds every decision as plain numbers and
is covered; audio/audio.ts is only wiring, which cannot run outside a browser.
A test caught panning being mirrored — +X is the car's left in this world, so the
sine needed negating.

Also adds hold-R-for-five-seconds to wipe the campaign, with a filling meter so
it cannot happen by accident, and M to mute. Tapping R still just respawns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino
2026-08-07 21:31:49 +02:00
co-authored by Claude Opus 5
parent f4ed3cdc48
commit 62c67ea649
10 changed files with 738 additions and 6 deletions
+290
View File
@@ -0,0 +1,290 @@
/**
* The audio graph. Integration layer: this is the only file that touches Web
* Audio, and it makes no decisions — every number it uses comes from mix.ts.
*
* Everything is synthesised. The build has to stay self-contained (no network
* at runtime), so there are no sample files: the engine is detuned oscillators,
* the tyres and gunfire are shaped noise, the ambience is a filtered drone.
* That also means the car can *sound* worn out rather than just performing worse.
*/
import type { CarCondition } from '../sim/car';
import type { Control } from '../sim/regions';
import {
ambienceFor,
engineTone,
impactLevel,
roadNoise,
spatial,
windGain,
GUNSHOT_RANGE,
IMPACT_FLOOR,
IMPACT_RANGE,
} from './mix';
const MUTE_KEY = 'dbtl.muted';
/** One second of white noise, reused as the source for everything hissy. */
function noiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
return buffer;
}
function loopingNoise(context: AudioContext, buffer: AudioBuffer): AudioBufferSourceNode {
const source = context.createBufferSource();
source.buffer = buffer;
source.loop = true;
source.start();
return source;
}
export interface AudioModel {
speed: number;
throttle: number;
condition: CarCondition;
/** Total lateral slip across the wheels, normalised-ish. */
sideSlip: number;
wheelsOnGround: number;
control: Control;
listener: { x: number; z: number; heading: number };
}
export function createAudio() {
const context = new AudioContext();
const noise = noiseBuffer(context);
// Master chain. The compressor is not for polish — a firefight plus an engine
// plus impacts will clip without it.
const master = context.createGain();
const compressor = context.createDynamicsCompressor();
compressor.threshold.value = -18;
compressor.ratio.value = 6;
master.connect(compressor).connect(context.destination);
let muted = localStorage.getItem(MUTE_KEY) === '1';
master.gain.value = muted ? 0 : 0.9;
// --- Engine: two detuned saws through a lowpass, plus a noise bed that
// stands in for combustion roughness as the engine wears out.
const engineGain = context.createGain();
engineGain.gain.value = 0;
const engineFilter = context.createBiquadFilter();
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 900;
engineGain.connect(engineFilter).connect(master);
const oscillators = [0, 1].map((index) => {
const osc = context.createOscillator();
osc.type = index === 0 ? 'sawtooth' : 'square';
osc.frequency.value = 40;
osc.detune.value = index === 0 ? 0 : 9;
const gain = context.createGain();
gain.gain.value = index === 0 ? 0.7 : 0.3;
osc.connect(gain).connect(engineGain);
osc.start();
return osc;
});
const roughGain = context.createGain();
roughGain.gain.value = 0;
const roughFilter = context.createBiquadFilter();
roughFilter.type = 'bandpass';
roughFilter.frequency.value = 180;
roughFilter.Q.value = 1.4;
loopingNoise(context, noise).connect(roughGain);
roughGain.connect(roughFilter).connect(engineGain);
// --- Road roar ---
const roadGain = context.createGain();
roadGain.gain.value = 0;
const roadFilter = context.createBiquadFilter();
roadFilter.type = 'lowpass';
roadFilter.frequency.value = 600;
loopingNoise(context, noise).connect(roadGain);
roadGain.connect(roadFilter).connect(master);
// --- Tyre squeal ---
const squealGain = context.createGain();
squealGain.gain.value = 0;
const squealFilter = context.createBiquadFilter();
squealFilter.type = 'bandpass';
squealFilter.frequency.value = 1500;
squealFilter.Q.value = 7;
loopingNoise(context, noise).connect(squealGain);
squealGain.connect(squealFilter).connect(master);
// --- Wind ---
const windGainNode = context.createGain();
windGainNode.gain.value = 0;
const windFilter = context.createBiquadFilter();
windFilter.type = 'highpass';
windFilter.frequency.value = 900;
loopingNoise(context, noise).connect(windGainNode);
windGainNode.connect(windFilter).connect(master);
// --- Ambience: a drone plus an airy layer, both shifting with territory ---
const droneOsc = context.createOscillator();
droneOsc.type = 'triangle';
droneOsc.frequency.value = 55;
const droneGain = context.createGain();
droneGain.gain.value = 0;
droneOsc.connect(droneGain).connect(master);
droneOsc.start();
const airGain = context.createGain();
airGain.gain.value = 0;
const airFilter = context.createBiquadFilter();
airFilter.type = 'bandpass';
airFilter.frequency.value = 2400;
airFilter.Q.value = 0.6;
loopingNoise(context, noise).connect(airGain);
airGain.connect(airFilter).connect(master);
/** Smoothly move a parameter; stepping them creates clicks. */
const ramp = (param: AudioParam, value: number, seconds = 0.06) => {
param.setTargetAtTime(value, context.currentTime, seconds);
};
/** A one-shot burst of shaped noise: gunfire, impacts, hits. */
function burst(options: {
level: number;
duration: number;
type: BiquadFilterType;
frequency: number;
Q?: number;
pan?: number;
sweepTo?: number;
}) {
if (options.level <= 0.001) return;
const now = context.currentTime;
const source = context.createBufferSource();
source.buffer = noise;
source.loop = true;
const filter = context.createBiquadFilter();
filter.type = options.type;
filter.frequency.value = options.frequency;
if (options.Q !== undefined) filter.Q.value = options.Q;
if (options.sweepTo !== undefined) {
filter.frequency.exponentialRampToValueAtTime(options.sweepTo, now + options.duration);
}
const gain = context.createGain();
gain.gain.setValueAtTime(options.level, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + options.duration);
const panner = context.createStereoPanner();
panner.pan.value = options.pan ?? 0;
source.connect(filter).connect(gain).connect(panner).connect(master);
source.start(now);
source.stop(now + options.duration + 0.02);
}
return {
get muted() {
return muted;
},
get suspended() {
return context.state === 'suspended';
},
/**
* Browsers refuse to make noise until the user has interacted, so this is
* called from the first keypress or click rather than at boot.
*/
resume() {
if (context.state === 'suspended') void context.resume();
},
toggleMute(): boolean {
muted = !muted;
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
ramp(master.gain, muted ? 0 : 0.9, 0.02);
return muted;
},
/** Continuous sounds, driven from the sim every frame. */
update(model: AudioModel) {
const engine = engineTone(model.speed, model.throttle, model.condition);
for (const osc of oscillators) ramp(osc.frequency, engine.frequency, 0.05);
ramp(engineGain.gain, engine.gain);
ramp(engineFilter.frequency, 500 + engine.frequency * 7);
// A worn engine gets louder in the wrong way, not quieter.
ramp(roughGain.gain, engine.roughness * 0.05);
ramp(roughFilter.frequency, engine.frequency * 3);
const road = roadNoise(model.speed, model.sideSlip, model.wheelsOnGround, model.condition);
ramp(roadGain.gain, road.gain);
ramp(roadFilter.frequency, road.cutoff);
ramp(squealGain.gain, road.squeal * 0.07, 0.03);
ramp(windGainNode.gain, windGain(model.speed));
const ambience = ambienceFor(model.control);
// Slow, so crossing a border is felt rather than heard as a switch.
ramp(droneGain.gain, ambience.droneGain, 1.5);
ramp(droneOsc.frequency, ambience.droneFrequency, 2);
ramp(airGain.gain, ambience.airGain, 1.5);
},
/** The car hit something. */
impact(force: number, at?: { x: number; z: number }, listener?: AudioModel['listener']) {
if (force < IMPACT_FLOOR) return;
const level = impactLevel(force);
const placed = at && listener ? spatial(listener, at, IMPACT_RANGE) : { gain: 1, pan: 0 };
burst({
level: level * 0.8 * placed.gain,
duration: 0.16 + level * 0.2,
type: 'lowpass',
frequency: 500,
sweepTo: 90,
pan: placed.pan,
});
},
/** Somebody fired, somewhere. */
shot(at: { x: number; z: number }, listener: AudioModel['listener']) {
const placed = spatial(listener, at, GUNSHOT_RANGE);
if (placed.gain <= 0.004) return;
burst({
level: 0.5 * placed.gain,
duration: 0.1,
type: 'highpass',
frequency: 700,
sweepTo: 180,
pan: placed.pan,
});
},
/** A round hit the car. Sharper and closer than anything else. */
hit() {
burst({ level: 0.55, duration: 0.14, type: 'bandpass', frequency: 2600, Q: 4 });
},
/** Radio traffic: a short burst of static under the text. */
radio() {
burst({ level: 0.09, duration: 0.22, type: 'bandpass', frequency: 1300, Q: 2.5 });
},
/** Board and menu blips. */
ui(kind: 'open' | 'accept') {
const osc = context.createOscillator();
const gain = context.createGain();
const now = context.currentTime;
osc.type = 'square';
osc.frequency.setValueAtTime(kind === 'accept' ? 520 : 380, now);
if (kind === 'accept') osc.frequency.exponentialRampToValueAtTime(780, now + 0.09);
gain.gain.setValueAtTime(0.05, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.12);
osc.connect(gain).connect(master);
osc.start(now);
osc.stop(now + 0.14);
},
};
}
export type Audio = ReturnType<typeof createAudio>;
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import { freshCondition, type CarCondition } from '../sim/car';
import { CONTROLS } from '../sim/regions';
import {
ambienceFor,
engineTone,
impactLevel,
roadNoise,
spatial,
windGain,
GUNSHOT_RANGE,
} from './mix';
const worn = (level: number): CarCondition => ({
level: { engine: level, tires: level, chassis: level },
ceiling: { engine: 1, tires: 1, chassis: 1 },
});
describe('engine note', () => {
it('climbs with speed', () => {
const idle = engineTone(0, 0, freshCondition());
const moving = engineTone(6, 1, freshCondition());
expect(moving.frequency).toBeGreaterThan(idle.frequency);
});
it('drops on an upshift instead of rising forever', () => {
// Without gears the engine is a siren wired to the speedometer.
const beforeShift = engineTone(8.9, 1, freshCondition());
const afterShift = engineTone(9.2, 1, freshCondition());
expect(afterShift.gear).toBe(beforeShift.gear + 1);
expect(afterShift.frequency).toBeLessThan(beforeShift.frequency);
});
it('keeps climbing overall, gear after gear', () => {
const first = engineTone(2, 1, freshCondition());
const last = engineTone(34, 1, freshCondition());
expect(last.gear).toBeGreaterThan(first.gear);
});
it('sounds rough and runs out of revs as the engine wears out', () => {
const good = engineTone(30, 1, freshCondition());
const bad = engineTone(30, 1, worn(0.2));
// The car's decline is audible, not just felt through the wheel.
expect(bad.roughness).toBeGreaterThan(good.roughness);
expect(bad.frequency).toBeLessThan(good.frequency);
expect(good.roughness).toBe(0);
});
it('is the same note whichever way the car is pointing', () => {
expect(engineTone(-20, 1, freshCondition()).frequency).toBeCloseTo(
engineTone(20, 1, freshCondition()).frequency,
9,
);
});
});
describe('tyres and wind', () => {
it('is silent with the wheels off the ground', () => {
const airborne = roadNoise(25, 0.5, 0, freshCondition());
expect(airborne.gain).toBe(0);
expect(airborne.squeal).toBe(0);
});
it('gets louder and brighter with speed', () => {
const slow = roadNoise(5, 0, 4, freshCondition());
const fast = roadNoise(30, 0, 4, freshCondition());
expect(fast.gain).toBeGreaterThan(slow.gain);
expect(fast.cutoff).toBeGreaterThan(slow.cutoff);
expect(windGain(30)).toBeGreaterThan(windGain(5));
});
it('stays quiet when the car is just going straight', () => {
expect(roadNoise(25, 0, 4, freshCondition()).squeal).toBe(0);
});
it('squeals sooner on bald tyres than on good ones', () => {
const slip = 0.6;
const good = roadNoise(25, slip, 4, freshCondition()).squeal;
const bald = roadNoise(25, slip, 4, worn(0.15)).squeal;
expect(bald).toBeGreaterThan(good);
});
});
describe('positional sound', () => {
const listener = { x: 0, z: 0, heading: 0 };
it('drops off with distance and cuts out past its range', () => {
const near = spatial(listener, { x: 0, z: 20 }, GUNSHOT_RANGE);
const far = spatial(listener, { x: 0, z: 200 }, GUNSHOT_RANGE);
expect(near.gain).toBeGreaterThan(far.gain);
expect(spatial(listener, { x: 0, z: GUNSHOT_RANGE + 1 }, GUNSHOT_RANGE).gain).toBe(0);
});
it('puts a sound on the correct side', () => {
// Forward is +Z and left is +X, so something at +X should be to the left.
expect(spatial(listener, { x: 40, z: 0 }, 200).pan).toBeLessThan(0);
expect(spatial(listener, { x: -40, z: 0 }, 200).pan).toBeGreaterThan(0);
expect(spatial(listener, { x: 0, z: 40 }, 200).pan).toBeCloseTo(0, 6);
});
it('follows the car round, rather than being fixed to the world', () => {
const source = { x: 40, z: 0 };
const ahead = spatial({ x: 0, z: 0, heading: -Math.PI / 2 }, source, 200);
// Turned to face it, the same sound should now be in front, not beside.
expect(Math.abs(ahead.pan)).toBeLessThan(0.2);
});
it('never pans beyond the speakers', () => {
for (const heading of [0, 1, 2, 3, 4, 5, 6]) {
const { pan } = spatial({ x: 0, z: 0, heading }, { x: 5, z: -5 }, 200);
expect(pan).toBeGreaterThanOrEqual(-1);
expect(pan).toBeLessThanOrEqual(1);
}
});
});
describe('impacts', () => {
it('ignores kerb scrapes and saturates on a real crash', () => {
expect(impactLevel(1000)).toBe(0);
expect(impactLevel(40000)).toBeGreaterThan(0);
expect(impactLevel(40000)).toBeLessThan(1);
expect(impactLevel(500000)).toBe(1);
});
});
describe('ambience', () => {
it('gets lower and heavier the further past the line you are', () => {
const drones = CONTROLS.map((c) => ambienceFor(c));
for (let i = 1; i < drones.length; i++) {
expect(drones[i]!.droneGain).toBeGreaterThan(drones[i - 1]!.droneGain);
expect(drones[i]!.droneFrequency).toBeLessThan(drones[i - 1]!.droneFrequency);
// And the airy top thins out as it does.
expect(drones[i]!.airGain).toBeLessThan(drones[i - 1]!.airGain);
}
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* What the game should sound like, as numbers. Pure — no Web Audio, no engine.
*
* The audio graph itself cannot be tested outside a browser, so everything that
* involves a decision — how engine pitch tracks speed, how a dying engine
* sounds, how far away a gunshot is still worth hearing — lives here where it
* can be. src/audio/audio.ts is then only wiring.
*/
import type { CarCondition } from '../sim/car';
import type { Control } from '../sim/regions';
// --- Engine ---------------------------------------------------------------
/** Idle note, in Hz. Low enough to feel like an engine and not a wasp. */
const IDLE_HZ = 38;
const REDLINE_HZ = 150;
/** Metres per second covered in each gear before it shifts up. */
const GEAR_SPAN = 9;
const GEARS = 4;
export interface EngineTone {
/** Fundamental frequency of the engine note. */
frequency: number;
gain: number;
/**
* 0..1 of added noise and instability. This is the car's decline made
* audible: a worn engine does not just go slower, it sounds wrong.
*/
roughness: number;
gear: number;
}
export function engineTone(speed: number, throttle: number, condition: CarCondition): EngineTone {
const fast = Math.abs(speed);
// Gears, so the note climbs and drops rather than rising forever. Without
// this the engine is a siren that tracks the speedometer.
const gear = Math.min(GEARS - 1, Math.floor(fast / GEAR_SPAN));
const withinGear = Math.min(1, (fast - gear * GEAR_SPAN) / GEAR_SPAN);
// A tired engine cannot reach the top of its range any more.
const ceiling = IDLE_HZ + (REDLINE_HZ - IDLE_HZ) * (0.55 + 0.45 * condition.level.engine);
const revs = IDLE_HZ + (ceiling - IDLE_HZ) * withinGear;
return {
// Leaning on the throttle lifts the note a little even at constant speed.
frequency: revs * (1 + 0.06 * Math.abs(throttle)),
gain: 0.09 + 0.06 * Math.abs(throttle) + 0.04 * Math.min(1, fast / 20),
roughness: Math.min(1, (1 - condition.level.engine) * 1.3),
gear,
};
}
// --- Tyres and wind -------------------------------------------------------
export interface RoadNoise {
/** Rolling noise. */
gain: number;
/** Squeal, from the tyres being asked for more grip than they have. */
squeal: number;
/** Cutoff for the rolling noise filter; faster is brighter. */
cutoff: number;
}
export function roadNoise(
speed: number,
sideSlip: number,
wheelsOnGround: number,
condition: CarCondition,
): RoadNoise {
const fast = Math.abs(speed);
const contact = wheelsOnGround / 4;
// Bald tyres let go sooner, so they protest sooner.
const grip = 0.35 + 0.65 * condition.level.tires;
return {
gain: Math.min(0.16, fast * 0.006) * contact,
squeal: Math.min(1, Math.max(0, (sideSlip / grip - 0.25) * 1.6)) * contact,
cutoff: 400 + Math.min(2600, fast * 130),
};
}
export const windGain = (speed: number): number => Math.min(0.1, Math.abs(speed) * 0.0042);
// --- Positional sound -----------------------------------------------------
export interface Spatial {
gain: number;
/** -1 hard left, +1 hard right. */
pan: number;
}
/**
* Gain and stereo position for something happening at a point in the world.
*
* Rolls off with the square of distance rather than linearly: a firefight two
* streets away should be present but not level with one you are parked in.
*/
export function spatial(
listener: { x: number; z: number; heading: number },
source: { x: number; z: number },
range: number,
): Spatial {
const dx = source.x - listener.x;
const dz = source.z - listener.z;
const distance = Math.hypot(dx, dz);
if (distance > range) return { gain: 0, pan: 0 };
const falloff = 1 - distance / range;
// Bearing relative to where the car is pointing, so panning turns with you.
const bearing = Math.atan2(dx, dz) - listener.heading;
return {
gain: falloff * falloff,
// Negated: in this world +X is to the car's *left*, so a positive bearing
// is a sound on the left and belongs in the left speaker.
pan: Math.max(-1, Math.min(1, -Math.sin(bearing))),
};
}
/** How far a gunshot carries. Generous: hearing it is the warning. */
export const GUNSHOT_RANGE = 260;
export const IMPACT_RANGE = 120;
// --- Ambience -------------------------------------------------------------
export interface Ambience {
/** Low drone under everything, tenser the further past the line you are. */
droneGain: number;
droneFrequency: number;
/** Airy top layer, thinner in hostile ground. */
airGain: number;
}
/**
* The tone of the place, matching what the palette does visually: home is open
* and quiet, the frontier is a low uneasy hum.
*/
const AMBIENCE: Record<Control, Ambience> = {
liberated: { droneGain: 0.012, droneFrequency: 55, airGain: 0.03 },
contested: { droneGain: 0.022, droneFrequency: 48, airGain: 0.022 },
occupied: { droneGain: 0.034, droneFrequency: 41, airGain: 0.014 },
frontier: { droneGain: 0.046, droneFrequency: 33, airGain: 0.008 },
};
export const ambienceFor = (control: Control): Ambience => AMBIENCE[control];
// --- One-shots ------------------------------------------------------------
/** Loudness of a collision, from the impact force the physics reported. */
export const impactLevel = (force: number): number =>
Math.min(1, Math.max(0, (force - 3000) / 90000));
/** Impacts below this are kerbs and scrapes, not worth a bang. */
export const IMPACT_FLOOR = 3500;