// The beds under the night: rain outside, bodies inside, and the kebab shop's // fluoro tube humming to itself while both happen. // // ROUTING. Everything here lands on the engine's sfxBus, never the musicBus. // The music lowpass models "the track heard through a wall" — that is a // statement about the PA being on the other side of a door. Rain is not on the // other side of anything; it is falling on your head, so filtering it with the // music would put the weather indoors with the speakers. What makes rain // door-only is its own gain envelope tracking `audio:location`, not a cutoff. // The crowd bed is the same argument run backwards: it is the room you are // standing in when you are inside, and a wall's worth of muffling belongs on // the PA, not on the people. Location is a mix decision here, not a filter one. import type { EventBus } from '../core/EventBus'; import { SeededRNG } from '../core/SeededRNG'; import type { Sfx } from './Sfx'; export interface AmbienceOptions { rain?: boolean; crowd?: boolean; kebab?: boolean; } type Bed = 'rain' | 'crowd' | 'kebab'; type Location = 'door' | 'floor'; /** Seconds of looped noise behind the crowd bed. Long enough to hide the seam. */ const NOISE_SECONDS = 4; /** Location crossfade. Sits between the 600/900ms filter sweeps either way, so * stepping through the door reads as one movement rather than two events. */ const LOC_FADE_S = 0.7; /** Headcount swell. Slow enough to be a swell, fast enough to feel caused. */ const COUNT_FADE_S = 0.4; /** Enable/disable toggles are a tuning affordance, not a game beat — keep short. */ const TOGGLE_FADE_S = 0.25; /** Peak crowd gain, at a room so full it cannot get louder. Well under the kick. */ const CROWD_PEAK = 0.09; /** * Saturation constant for the headcount curve. At k=14 the first dozen people * buy most of the loudness and the hundredth buys almost none — which is how a * room actually behaves, and the opposite of what a linear map does. */ const CROWD_K = 14; /** Crowd bed at the door: the room leaking past you, not silence. */ const CROWD_DOOR_DUCK = 0.15; /** The fluoro tube. Barely there on purpose — it is set dressing, not a voice. */ const KEBAB_LEVEL = 0.014; export class Ambience { private readonly bus: EventBus; private readonly ctx: AudioContext; private readonly dest: AudioNode; private readonly sfx: Sfx; private readonly offs: Array<() => void> = []; private readonly enabled: Record; private loc: Location = 'door'; private running = false; private count = 0; private crowd: CrowdBed | null = null; private kebab: KebabBed | null = null; /** Built on first start() and kept: restarting shouldn't re-allocate 4s of noise. */ private noise: AudioBuffer | null = null; constructor( bus: EventBus, ctx: AudioContext, destination: AudioNode, sfx: Sfx, opts: AmbienceOptions = {}, ) { this.bus = bus; this.ctx = ctx; this.dest = destination; this.sfx = sfx; this.enabled = { rain: opts.rain ?? true, crowd: opts.crowd ?? true, // The kebab shop is the moral centre of the thing (design doc §5). It gets // a sound whether or not anyone is listening for it. kebab: opts.kebab ?? true, }; this.offs.push( this.bus.on('audio:location', ({ location }) => { if (location === this.loc) return; // door -> door must not restack a fade this.loc = location; this.applyLocation(); }), ); // Headcount is derived, not published: NightState.capacity.inside is in no // event payload and we may not add one. So we count the truth log — an // admission puts a body in the room, an ejection takes one out. // // Deliberately NOT door:clicker. The clicker is the player's *claimed* // count, and the gap between it and reality is a game mechanic. Driving the // crowd bed off it would make the room sound like the lie instead of the // room, and would hand the player a way to hear their own miscount. this.offs.push( this.bus.on('door:verdict', ({ verdict }) => { if (verdict === 'admit') this.setInsideCount(this.count + 1); }), ); this.offs.push( this.bus.on('floor:ejection', () => this.setInsideCount(this.count - 1)), ); } get insideCount(): number { return this.count; } /** Lets a demo drive the room without staging a hundred real admissions. */ setInsideCount(n: number): void { const next = Math.max(0, Math.floor(n)); if (next === this.count) return; this.count = next; this.crowd?.setLevel(this.crowdTarget(), COUNT_FADE_S); } start(): void { if (this.running) return; this.running = true; this.noise ??= noiseBuffer(this.ctx); this.crowd = new CrowdBed(this.ctx, this.dest, this.noise); this.kebab = new KebabBed(this.ctx, this.dest); // Snap rather than fade on the first frame: there is no previous location to // cross from, and a 700ms swell out of nothing sounds like a mistake. this.crowd.setLevel(this.crowdTarget(), 0); this.crowd.setLocation(this.crowdDuck(), 0); this.kebab.setLevel(this.kebabTarget(), 0); this.kebab.setLocation(this.kebabDuck(), 0); this.applyRain(); } stop(): void { if (!this.running) return; this.running = false; this.crowd?.stop(); this.crowd = null; this.kebab?.stop(); this.kebab = null; this.sfx.stopRain(); } setEnabled(bed: Bed, on: boolean): void { if (this.enabled[bed] === on) return; this.enabled[bed] = on; if (!this.running) return; switch (bed) { case 'rain': return this.applyRain(); case 'crowd': return this.crowd?.setLevel(this.crowdTarget(), TOGGLE_FADE_S); case 'kebab': return this.kebab?.setLevel(this.kebabTarget(), TOGGLE_FADE_S); } } destroy(): void { this.stop(); for (const off of this.offs) off(); this.offs.length = 0; } // --- policy --------------------------------------------------------------- private applyLocation(): void { this.crowd?.setLocation(this.crowdDuck(), LOC_FADE_S); this.kebab?.setLocation(this.kebabDuck(), LOC_FADE_S); this.applyRain(); } /** * Rain is delegated to Sfx, which already owns a rain bed wired to this same * bus; a second one would just be the first one twice as loud. Both calls are * idempotent, so repeated door -> door is a no-op there as well as here. * * The `running` guard is what the crowd and kebab beds get for free from their * null refs. Without it a location change after stop() — the subscription * outlives stop(), only destroy() drains it — would start rain under a room * with no crowd and no kebab in it, and nothing would take it back down. */ private applyRain(): void { if (!this.running) return; if (this.enabled.rain && this.loc === 'door') this.sfx.startRain(); else this.sfx.stopRain(); } private crowdTarget(): number { if (!this.enabled.crowd) return 0; return CROWD_PEAK * (1 - Math.exp(-this.count / CROWD_K)); } private crowdDuck(): number { return this.loc === 'floor' ? 1 : CROWD_DOOR_DUCK; } private kebabTarget(): number { return this.enabled.kebab ? KEBAB_LEVEL : 0; } private kebabDuck(): number { return this.loc === 'door' ? 1 : 0; } } /** * Bodies and talking. A wide bandpass around 500Hz is the whole trick: above * ~1kHz noise turns into hiss and below ~250Hz it turns into traffic, and the * band between them is where a room full of people sits. */ class CrowdBed { private readonly src: AudioBufferSourceNode; private readonly lfos: OscillatorNode[] = []; private readonly chain: AudioNode[] = []; private readonly level: GainNode; private readonly loc: GainNode; private readonly ctx: AudioContext; constructor(ctx: AudioContext, dest: AudioNode, buffer: AudioBuffer) { this.ctx = ctx; this.src = ctx.createBufferSource(); this.src.buffer = buffer; this.src.loop = true; // Slowed a little: it drags the noise's spectral centre down and costs nothing. this.src.playbackRate.value = 0.85; const band = ctx.createBiquadFilter(); band.type = 'bandpass'; band.frequency.value = 500; band.Q.value = 0.75; // ~300-800Hz at -3dB // Second pass shaves the fizz the bandpass's gentle skirt lets through. const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 1100; // Two slow LFOs on a multiplying node, not on the level itself — swelling a // gain of 0 would push it negative, and an empty room has to be silent. const wobble = ctx.createGain(); wobble.gain.value = 1; this.wobbler(wobble, 0.19, 0.12); this.wobbler(wobble, 0.07, 0.07); this.level = ctx.createGain(); this.level.gain.value = 0; this.loc = ctx.createGain(); this.loc.gain.value = 0; this.chain.push(band, lp, wobble, this.level, this.loc); this.src.connect(band).connect(lp).connect(wobble).connect(this.level).connect(this.loc); this.loc.connect(dest); this.src.start(); } setLevel(to: number, durS: number): void { ramp(this.ctx, this.level.gain, to, durS); } setLocation(to: number, durS: number): void { ramp(this.ctx, this.loc.gain, to, durS); } stop(): void { stopBed(this.ctx, this.loc, this.src, this.lfos, this.chain); } private wobbler(target: GainNode, hz: number, depth: number): void { const lfo = this.ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = hz; const amount = this.ctx.createGain(); amount.gain.value = depth; lfo.connect(amount).connect(target.gain); lfo.start(); this.lfos.push(lfo); this.chain.push(amount); } } /** * The kebab shop's fluoro tube, heard from the footpath. 100Hz because a tube * buzzes at twice mains frequency; the 50Hz underneath is the ballast doing the * work. Mono and completely dry — it is six metres away through an open * shopfront, and reverb would put it in a cathedral. */ class KebabBed { private readonly oscs: OscillatorNode[] = []; private readonly chain: AudioNode[] = []; private readonly level: GainNode; private readonly loc: GainNode; private readonly ctx: AudioContext; constructor(ctx: AudioContext, dest: AudioNode) { this.ctx = ctx; const sum = ctx.createGain(); sum.gain.value = 1; // Fundamental, mains harmonic, and a thin 200Hz that keeps it from reading // as a clean test tone. this.partial(100, 1, sum); this.partial(50, 0.45, sum); this.partial(200, 0.18, sum); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 400; // A dying tube breathes. 0.6Hz at 8% is under conscious notice and is the // difference between "hum" and "sine wave someone left running". const flicker = ctx.createGain(); flicker.gain.value = 1; const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 0.6; const depth = ctx.createGain(); depth.gain.value = 0.08; lfo.connect(depth).connect(flicker.gain); lfo.start(); this.oscs.push(lfo); this.level = ctx.createGain(); this.level.gain.value = 0; this.loc = ctx.createGain(); this.loc.gain.value = 0; this.chain.push(sum, lp, depth, flicker, this.level, this.loc); sum.connect(lp).connect(flicker).connect(this.level).connect(this.loc); this.loc.connect(dest); } setLevel(to: number, durS: number): void { ramp(this.ctx, this.level.gain, to, durS); } setLocation(to: number, durS: number): void { ramp(this.ctx, this.loc.gain, to, durS); } stop(): void { stopBed(this.ctx, this.loc, null, this.oscs, this.chain); } private partial(hz: number, gain: number, sum: GainNode): void { const osc = this.ctx.createOscillator(); osc.type = 'sine'; osc.frequency.value = hz; const g = this.ctx.createGain(); g.gain.value = gain; osc.connect(g).connect(sum); osc.start(); this.oscs.push(osc); this.chain.push(g); } } // --- plumbing --------------------------------------------------------------- /** * Linear throughout, unlike the engine's sweeps: these targets legitimately * reach 0 (empty room, bed disabled) and an exponential ramp to zero throws. * Re-anchoring at the live value first keeps an interrupted fade continuous — * players do walk in and straight back out. * * Read `param.value` BEFORE cancelling, same as `TechnoEngine.ramp()`. * `cancelScheduledValues(now)` removes every event at or after `now`, including * the in-flight ramp's end event, and the spec does not say what the getter * reads afterwards: Blink returns the last rendered value, but a UA computing it * from the surviving timeline returns the ramp's origin. Reading first pins the * mid-ramp value the player is actually hearing, so a crowd bed 350ms into a * 0.15 -> 1 door->floor swell fades from ~0.57 rather than jumping down to 0.15. * * `cancelAndHoldAtTime` holds that value at `now` instead of discarding it, * which is the same thing done properly — but Firefox has never shipped it, so * feature-detect and fall back. */ function ramp(ctx: AudioContext, param: AudioParam, to: number, durS: number): void { const now = ctx.currentTime; const from = param.value; if (typeof param.cancelAndHoldAtTime === 'function') param.cancelAndHoldAtTime(now); else param.cancelScheduledValues(now); param.setValueAtTime(from, now); if (durS <= 0) param.setValueAtTime(to, now); else param.linearRampToValueAtTime(to, now + durS); } /** * Fade out, then stop and disconnect everything after the fade lands. Cutting a * running noise bed is audible as a click, and leaving the nodes attached means * a six-hour night accumulates every bed it ever started. */ function stopBed( ctx: AudioContext, out: GainNode, src: AudioBufferSourceNode | null, oscs: readonly OscillatorNode[], chain: readonly AudioNode[], ): void { const fade = 0.25; const at = ctx.currentTime + fade; ramp(ctx, out.gain, 0, fade); const tail = src ?? oscs[0]; if (tail) { tail.onended = () => { for (const n of chain) n.disconnect(); src?.disconnect(); for (const o of oscs) o.disconnect(); }; } src?.stop(at); for (const o of oscs) o.stop(at); } /** Deterministic white noise — house rule forbids Math.random, and a fixed seed * means the room sounds the same on every run. */ function noiseBuffer(ctx: AudioContext): AudioBuffer { const len = Math.floor(ctx.sampleRate * NOISE_SECONDS); const buf = ctx.createBuffer(1, len, ctx.sampleRate); const data = buf.getChannelData(0); const rng = new SeededRNG(0x4b3b).stream('crowd-noise'); for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1; return buf; }