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>
446 lines
15 KiB
TypeScript
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;
|
|
}
|