not-tonight/src/audio/Ambience.ts
type-two 1a66c602fd LANE-JUICE2: ear-pass mix desk, ambience beds, night arc in the engine
The Phase-1 debt was 'verified by ear: NOTHING'. I still can't hear, so this
lane builds the instrument that lets someone who can tell me what's wrong, and
makes their answer a diff instead of a conversation.

- TechnoEngine drives every voice from the live TrackMix and the arc, instead of
  module consts. Behaviour-identical at DEFAULT_MIX (verified constant by
  constant). Voices whose effective gain rounds to silence are skipped, not
  clamped — at 9PM the bass builds zero nodes rather than ~8/sec of silence.
  setMixValue quantises integer paths, so what formatMix dumps is what played.
- MixDeskScene: solo/mute per voice with a per-voice listening prompt, every
  mix param on log-mapped sliders, an arc scrubber (audition 1AM at 9PM), and
  DUMP MIX -> a pasteable TrackMix literal. Restores the arc pin and solo state
  it found on close, rather than forcing a default — the demo deliberately pins
  PEAK and the desk was silently un-tuning it.
- Ambience: rain outside, crowd murmur inside, kebab-shop fluoro hum. On the SFX
  bus, not the music lowpass — that filter models the PA being behind a wall,
  and rain is not behind anything. Headcount comes off door:verdict admits and
  floor:ejection, NOT door:clicker: the clicker is the player's claimed count and
  its divergence from reality is the mechanic, so the room would sound like the
  lie. Verified live: 120 + 2 admits - 1 ejection = 121, clicker ignored.
- JuiceDemoScene pins the arc at PEAK on entry. GameClock runs 360 game-min in
  13 real min, so an unpinned demo opens on kick+hat and anyone judging the bass
  would conclude it's broken.
- Phone thread scrolls with visible-window culling and a binary-searched range,
  a history cap, and a 'new below' cue when a text lands off-screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:47:53 +10:00

446 lines
15 KiB
TypeScript

// 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<Bed, boolean>;
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;
}