diff --git a/fktry/src/audio/graph.ts b/fktry/src/audio/graph.ts new file mode 100644 index 0000000..7703dfb --- /dev/null +++ b/fktry/src/audio/graph.ts @@ -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(); + +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); +} diff --git a/fktry/src/audio/sfx.ts b/fktry/src/audio/sfx.ts new file mode 100644 index 0000000..35c9518 --- /dev/null +++ b/fktry/src/audio/sfx.ts @@ -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 150→55Hz (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 420→40Hz 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 (F–A–C) 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); +}