[audio] THE SPEAKER layer 1: the diegetic minimum

Placement thunk, fax dot-matrix chatter, brownout pop-crackle-and-power-down,
scram klaxon, ratification sting. All synthesized, no asset files, ever.

Every sound takes (ctx, dest, t0) and nothing assumes a live context, so the
whole speaker renders in an OfflineAudioContext for deterministic spectral
verification -- the same code the player hears, no test-only path. Noise
buffers use a seeded PRNG so spectra reproduce exactly.

The limiter is a tanh WaveShaper, not a DynamicsCompressor: makeup gain would
pump the whole mix every time the klaxon fires.

The ratification sting is the only consonant, in-tune sound in the game. Its
attack is slow where everything else is percussive, because the enemy is the
one thing here that resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 13:51:24 +10:00
parent 64cb3edec6
commit 7f6ebe5f5a
2 changed files with 329 additions and 0 deletions

125
fktry/src/audio/graph.ts Normal file
View File

@ -0,0 +1,125 @@
/**
* THE SPEAKER's plumbing: master chain, shared noise, scheduling helpers.
*
* HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever. A game about
* broken media makes its own noise.
*
* Two rules that shape everything here:
* - **Zero per-frame allocations.** Continuous voices (hum, station beds) are built once at
* unlock and then only have their AudioParams driven. Only genuinely one-shot sounds
* allocate nodes, and those are short-lived and self-stopping.
* - **Context-agnostic.** Nothing below assumes a live AudioContext. Every builder takes a
* BaseAudioContext, so the whole synth stack can be rendered in an OfflineAudioContext for
* deterministic spectral verification the same code the player hears.
*/
/** Noise buffers are expensive to fill, so each context gets one of each, cached. */
const noiseCache = new WeakMap<BaseAudioContext, { white: AudioBuffer; brown: AudioBuffer }>();
function buildNoise(ctx: BaseAudioContext) {
const len = Math.floor(ctx.sampleRate * 2); // 2s loops — long enough to not read as periodic
const white = ctx.createBuffer(1, len, ctx.sampleRate);
const brown = ctx.createBuffer(1, len, ctx.sampleRate);
const w = white.getChannelData(0);
const b = brown.getChannelData(0);
// Deterministic PRNG: same noise every run, so spectra are reproducible across verifications.
let seed = 0x1337c0de;
const rnd = () => {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296 * 2 - 1;
};
let last = 0;
for (let i = 0; i < len; i++) {
const n = rnd();
w[i] = n;
// brown/red noise: integrated white, gently leaked to stop DC runaway
last = (last + 0.02 * n) / 1.02;
b[i] = last * 3.5;
}
return { white, brown };
}
export function noise(ctx: BaseAudioContext) {
let n = noiseCache.get(ctx);
if (!n) {
n = buildNoise(ctx);
noiseCache.set(ctx, n);
}
return n;
}
/** A looping noise source. Caller owns start/stop. */
export function noiseSource(ctx: BaseAudioContext, kind: 'white' | 'brown'): AudioBufferSourceNode {
const src = ctx.createBufferSource();
src.buffer = noise(ctx)[kind];
src.loop = true;
return src;
}
export interface MasterChain {
/** everything in the mix connects here */
input: GainNode;
/** master volume, pre-limiter */
volume: GainNode;
}
/**
* gain soft limiter destination.
*
* The limiter is a WaveShaper running tanh, not a DynamicsCompressor: a compressor's lookahead
* and automatic makeup would pump the whole mix every time the klaxon fires. tanh just refuses
* to go past 1, which is what "don't clip" actually means, and it adds the faint saturation a
* blown factory PA ought to have.
*/
export function createMasterChain(ctx: BaseAudioContext): MasterChain {
const input = ctx.createGain();
const volume = ctx.createGain();
volume.gain.value = 0.9;
const limiter = ctx.createWaveShaper();
const n = 1024;
const curve = new Float32Array(n);
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * 2 - 1;
curve[i] = Math.tanh(x * 1.6);
}
limiter.curve = curve;
limiter.oversample = '2x';
input.connect(volume);
volume.connect(limiter);
limiter.connect(ctx.destination);
return { input, volume };
}
/** Ramp a param without clicks. Web Audio hates instantaneous jumps on a running graph. */
export function glide(p: AudioParam, to: number, ctx: BaseAudioContext, seconds = 0.05) {
const t = ctx.currentTime;
p.cancelScheduledValues(t);
p.setValueAtTime(p.value, t);
p.linearRampToValueAtTime(to, t + seconds);
}
/**
* A one-shot envelope on a fresh gain node: attack to peak, decay to silence, then disconnect.
* Returns the gain so the caller can hang a source off it.
*/
export function envGain(
ctx: BaseAudioContext,
t0: number,
peak: number,
attack: number,
decay: number,
): GainNode {
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t0 + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay);
return g;
}
/** Deterministic 0..1 hash — used anywhere a sound needs stable variation without Math.random. */
export function hash01(n: number): number {
const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}

204
fktry/src/audio/sfx.ts Normal file
View File

@ -0,0 +1,204 @@
/**
* Layer 1 the diegetic minimum. Always on, from the first click, before any discovery.
*
* These five sounds are the whole audio world until the player digs up the optical fossil.
* That is the point: the silence around them is the tease. Everything here is one-shot and
* self-stopping no continuous voices, so a pre-discovery factory is genuinely quiet between
* events rather than humming under a noise floor.
*
* Every sound takes (ctx, dest, t0) so it can be rendered offline for spectral verification.
*/
import { envGain, hash01, noiseSource } from './graph';
/**
* PLACEMENT THUNK a heavy thing set down on a metal deck.
* Low sine drops 15055Hz (the mass) under a filtered noise slap (the contact).
*/
export function thunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const detune = 1 + (hash01(variant) - 0.5) * 0.18; // no two placements identical
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(150 * detune, t0);
osc.frequency.exponentialRampToValueAtTime(55 * detune, t0 + 0.11);
const og = envGain(ctx, t0, 0.5, 0.004, 0.16);
osc.connect(og).connect(dest);
osc.start(t0);
osc.stop(t0 + 0.22);
const slap = noiseSource(ctx, 'white');
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 1400 * detune;
bp.Q.value = 0.8;
const sg = envGain(ctx, t0, 0.22, 0.002, 0.05);
slap.connect(bp).connect(sg).connect(dest);
slap.start(t0);
slap.stop(t0 + 0.09);
}
/**
* FAX CHATTER dot-matrix print head. A burst of short square blips at ~55Hz repetition
* with a paper-feed hiss under it. The commission fax is the game's voice; it should sound
* like an office machine that has never once been serviced.
*/
export function faxChatter(ctx: BaseAudioContext, dest: AudioNode, t0: number, chars = 14) {
const bus = ctx.createGain();
bus.gain.value = 0.5;
bus.connect(dest);
for (let i = 0; i < chars; i++) {
const t = t0 + i * 0.018 + hash01(i * 7.7) * 0.004;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = 320 + hash01(i * 3.3) * 260;
const g = envGain(ctx, t, 0.16, 0.0015, 0.012);
o.connect(g).connect(bus);
o.start(t);
o.stop(t + 0.02);
}
// paper feed
const paper = noiseSource(ctx, 'white');
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 2600;
const pg = envGain(ctx, t0, 0.09, 0.02, chars * 0.018 + 0.1);
paper.connect(hp).connect(pg).connect(bus);
paper.start(t0);
paper.stop(t0 + chars * 0.018 + 0.2);
}
/**
* BROWNOUT the codex's catastrophic underrun, finally audible.
* A hard pop transient, a burst of crackle, and a power-down whine sliding 42040Hz as the
* rails collapse. This is the single most important sound in the game: it is the punishment.
*/
export function brownoutHit(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
// the pop: a single-sample-ish click, deliberately broadband
const pop = ctx.createOscillator();
pop.type = 'square';
pop.frequency.setValueAtTime(90, t0);
const pg = envGain(ctx, t0, 0.75, 0.0008, 0.03);
pop.connect(pg).connect(dest);
pop.start(t0);
pop.stop(t0 + 0.04);
// crackle: brown noise gated hard
const cr = noiseSource(ctx, 'brown');
const crf = ctx.createBiquadFilter();
crf.type = 'bandpass';
crf.frequency.value = 800;
crf.Q.value = 0.6;
const cg = envGain(ctx, t0, 0.5, 0.003, 0.5);
cr.connect(crf).connect(cg).connect(dest);
cr.start(t0);
cr.stop(t0 + 0.7);
// power-down whine: the flywheel losing it
const wh = ctx.createOscillator();
wh.type = 'sawtooth';
wh.frequency.setValueAtTime(420, t0);
wh.frequency.exponentialRampToValueAtTime(40, t0 + 0.9);
const wf = ctx.createBiquadFilter();
wf.type = 'lowpass';
wf.frequency.setValueAtTime(2200, t0);
wf.frequency.exponentialRampToValueAtTime(300, t0 + 0.9);
const wg = envGain(ctx, t0, 0.3, 0.02, 0.95);
wh.connect(wf).connect(wg).connect(dest);
wh.start(t0);
wh.stop(t0 + 1.0);
}
/** Power coming back: the whine in reverse, shorter and less dramatic. Relief, not triumph. */
export function brownoutRecover(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const wh = ctx.createOscillator();
wh.type = 'sawtooth';
wh.frequency.setValueAtTime(60, t0);
wh.frequency.exponentialRampToValueAtTime(360, t0 + 0.35);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1400;
const g = envGain(ctx, t0, 0.2, 0.05, 0.32);
wh.connect(lp).connect(g).connect(dest);
wh.start(t0);
wh.stop(t0 + 0.45);
}
/**
* SCRAM KLAXON two-tone industrial alarm, 620/440Hz, square, deliberately unpleasant.
* An OSHA-compliant noise for an entirely non-compliant machine.
*/
export function klaxon(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const bus = ctx.createGain();
bus.gain.value = 0.34;
bus.connect(dest);
const tones = [620, 440, 620, 440];
for (let i = 0; i < tones.length; i++) {
const t = t0 + i * 0.22;
const o = ctx.createOscillator();
o.type = 'square';
o.frequency.value = tones[i];
const g = envGain(ctx, t, 0.5, 0.01, 0.19);
// a lowpass keeps it a klaxon rather than a buzzer
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1800;
o.connect(lp).connect(g).connect(bus);
o.start(t);
o.stop(t + 0.21);
}
}
/**
* RATIFICATION STING The Correction resolves; you don't.
* A clean major triad (FAC) on sines with a soft bell attack. It is the ONLY consonant,
* in-tune, untroubled sound in the entire game. Everything the player makes is filth; this
* is what the enemy sounds like, and it sounds *nice*, which is the joke.
*/
export function ratifySting(ctx: BaseAudioContext, dest: AudioNode, t0: number) {
const bus = ctx.createGain();
bus.gain.value = 0.3;
bus.connect(dest);
const triad = [349.23, 440.0, 523.25]; // F4 A4 C5
triad.forEach((f, i) => {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f;
const g = envGain(ctx, t0 + i * 0.035, 0.4, 0.02, 1.1);
o.connect(g).connect(bus);
o.start(t0 + i * 0.035);
o.stop(t0 + 1.4);
});
// a touch of shimmer an octave up so it reads as "official" rather than "warm"
const shimmer = ctx.createOscillator();
shimmer.type = 'sine';
shimmer.frequency.value = 1046.5;
const sg = envGain(ctx, t0, 0.12, 0.03, 0.9);
shimmer.connect(sg).connect(bus);
shimmer.start(t0);
shimmer.stop(t0 + 1.1);
}
/** TAPE CHUNK — a shipment lands. Layer 3, but it lives here with its siblings. */
export function tapeChunk(ctx: BaseAudioContext, dest: AudioNode, t0: number, variant = 0) {
const d = 1 + (hash01(variant * 5.5) - 0.5) * 0.2;
const n = noiseSource(ctx, 'white');
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 520 * d;
bp.Q.value = 1.4;
const g = envGain(ctx, t0, 0.3, 0.002, 0.07);
n.connect(bp).connect(g).connect(dest);
n.start(t0);
n.stop(t0 + 0.1);
const thud = ctx.createOscillator();
thud.type = 'sine';
thud.frequency.setValueAtTime(110 * d, t0);
thud.frequency.exponentialRampToValueAtTime(70 * d, t0 + 0.06);
const tg = envGain(ctx, t0, 0.22, 0.003, 0.09);
thud.connect(tg).connect(dest);
thud.start(t0);
thud.stop(t0 + 0.12);
}