import type { EventBus } from '../core/EventBus'; import { SeededRNG } from '../core/SeededRNG'; import { type ArcScales, arcAt } from './arc'; import { type AudioLocation, cutoffFor, gainFor, sweepMsFor, } from './filterCurve'; import { DEFAULT_MIX, MIX_RANGES, type TrackMix, VOICE_NAMES, type VoiceName, clampTo, cloneMix, voiceGain, } from './mix'; import { type BeatGrid, barOf, beatIntervalMs, beatTimeMs, dueBeats, isDownbeat, patternStep, subdivisions, } from './scheduling'; const DEFAULT_BPM = 128; const DEFAULT_LOOKAHEAD_MS = 100; const TIMER_MS = 25; /** * Beat 0 sits this far past the resume instant. Scheduling into the past is not * an error — the node just fires immediately — but then the beat *sounds* later * than the `audioTimeMs` we promised for it. The toilet rhythm game scores * against that number, so the first beat has to be as honest as the rest. */ const START_LEAD_MS = 60; const SILENT = 0.0001; // exponential ramps cannot reach 0 const BASS_STEPS = 32; // 2 bars of 16ths /** Seconds to the top of the bass filter sweep. Shape, not level — not tunable. */ const BASS_SWEEP_S = 0.03; /** * The bass tail settles a touch above the sweep floor: `filterLoHz` is where the * note *starts*, and resting exactly there makes the release sound like a gate. * Expressed as a ratio so retuning the floor carries the rest point with it. */ const BASS_TAIL_RATIO = 200 / 180; /** Fraction of the pad cycle spent fading in; the rest fades out. */ const PAD_RISE = 0.4; /** A slider drag on `master` should not zipper, but should not lag either. */ const KNOB_RAMP_S = 0.03; /** Mix paths the engine rounds at use-time, so they must be stored rounded too. */ const INTEGER_PATHS = new Set(['pad.bars']); /** * Semitones from the bass root, one entry per 16th. Nothing lands on a downbeat * — the kick owns that slot, and leaving it empty is what makes the bassline * roll instead of thud. */ const BASS_PATTERN: readonly (number | null)[] = [ null, null, 0, 0, null, 0, null, 0, null, null, 3, 3, null, 0, null, 12, null, null, 0, 0, null, 0, null, 0, null, null, 5, 3, null, 0, 2, null, ]; /** A minor, voiced wide and low. Detuning the pair is the whole character. */ const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorType }> = [ { hz: 110, detune: -7, type: 'sawtooth' }, { hz: 110, detune: 7, type: 'sawtooth' }, { hz: 164.81, detune: 0, type: 'triangle' }, ]; /** Widened once so a dotted path can be looked up without a cast at each site. */ const RANGES: Record = MIX_RANGES; const GROUP_NAMES: readonly VoiceName[] = VOICE_NAMES; interface MixLens { read: () => number; write: (v: number) => void; range: readonly [number, number]; } /** * The engine currently owning the AudioContext, or null. * * The tuning desk has to reach the engine during a real night, when the scene * that constructed it belongs to another lane and there is no route to it. A * module variable is the smallest thing that solves that — deliberately not a * window global, which would make it part of the page's public surface. */ let activeEngine: TechnoEngine | null = null; export const getActiveEngine = (): TechnoEngine | null => activeEngine; export interface TechnoEngineOptions { bpm?: number; lookaheadMs?: number; } /** * The looping techno track and the beat authority in one. Replaces * core/StubBeatClock — same `beat:tick` contract, but the beats now come off the * AudioContext clock instead of a frame counter, so they are actually true. * * Everything is synthesized; there are no samples to load and nothing to * preload. All scheduling happens on a 25ms timer looking `lookaheadMs` into the * future. That timer is throttled on a blurred tab exactly as rAF is, so the * look-ahead alone does not save us; what does is `resync()`, which drops the * span that elapsed while we were asleep instead of dumping it into the graph. * * Every level and shape comes off a live `TrackMix` scaled by the night arc, so * the track can be retuned and auditioned mid-playback. Nothing is read at * construction that is not re-read on the next beat. */ export class TechnoEngine { readonly ctx: AudioContext; readonly master: GainNode; readonly musicBus: GainNode; readonly sfxBus: GainNode; readonly bpm: number; private readonly filter: BiquadFilterNode; private readonly noiseBuffer: AudioBuffer; private readonly lookaheadMs: number; private readonly grid: BeatGrid; private readonly unsubs: Array<() => void> = []; /** * Long-lived handles so stop() can silence what is already on the schedule. * Keyed by source, valued by the rest of that voice's chain, because stop() * pre-empts `onended` and would otherwise strand those nodes on the bus. */ private readonly voices = new Map(); private mixState: TrackMix = cloneMix(DEFAULT_MIX); private clockMinute = 0; private pinnedMinute: number | null = null; /** Cached so the scheduler is not re-interpolating the arc ~20 times a second. */ private scales: ArcScales = arcAt(0); private timer: ReturnType | null = null; private nextBeat = 0; private loc: AudioLocation = 'door'; private override: number | null = null; private unlockedFlag = false; private unlocking: Promise | null = null; constructor( private readonly bus: EventBus, opts: TechnoEngineOptions = {}, ) { this.bpm = opts.bpm ?? DEFAULT_BPM; this.lookaheadMs = opts.lookaheadMs ?? DEFAULT_LOOKAHEAD_MS; this.ctx = new AudioContext(); this.grid = { bpm: this.bpm, originMs: 0 }; this.master = this.ctx.createGain(); this.master.gain.value = this.mixState.master; this.master.connect(this.ctx.destination); this.filter = this.ctx.createBiquadFilter(); this.filter.type = 'lowpass'; this.filter.frequency.value = cutoffFor(this.loc); this.filter.Q.value = this.qFor(this.loc); this.filter.connect(this.master); this.musicBus = this.ctx.createGain(); this.musicBus.gain.value = gainFor(this.loc); this.musicBus.connect(this.filter); // Deliberately NOT through the filter: a stamp must sound like a stamp even // when the music is buried behind a door. this.sfxBus = this.ctx.createGain(); this.sfxBus.connect(this.master); this.noiseBuffer = this.buildNoise(); this.unsubs.push( this.bus.on('audio:location', ({ location }) => this.setLocation(location)), this.bus.on('clock:tick', ({ clockMin }) => { this.clockMinute = clockMin; if (this.pinnedMinute === null) this.scales = arcAt(clockMin); }), ); // eslint-disable-next-line @typescript-eslint/no-this-alias -- the registry is the point activeEngine = this; } get running(): boolean { return this.timer !== null; } get unlocked(): boolean { return this.unlockedFlag; } get cutoffHz(): number { return this.filter.frequency.value; } get location(): AudioLocation { return this.loc; } // --- mix ------------------------------------------------------------------ get mix(): Readonly { return this.mixState; } /** * Set one knob by dotted path: `'master'`, `'levels.'`, * `'.'`, `'doorQ'`, `'floorQ'`. * * An unknown path is inert rather than fatal — the tuning desk is generated * from a table, and a typo there should cost one dead slider, not the night. */ setMixValue(path: string, value: number): void { const lens = this.lens(path); if (!lens) return; // Quantise here rather than at the slider: what we store is what formatMix // dumps, so a fractional bar count would export a value nobody ever heard. // The engine owns the invariant because it is the thing that rounds at use. const v = clampTo(INTEGER_PATHS.has(path) ? Math.round(value) : value, lens.range); lens.write(v); // Levels and shapes land on the next scheduled note (<500ms, inaudible as // lag). These two are continuous, so they have to move now. if (path === 'master') this.ramp(this.master.gain, v, KNOB_RAMP_S, false); if (path === this.loc + 'Q' && this.override === null) { this.ramp(this.filter.Q, v, KNOB_RAMP_S, false); } } /** NaN for an unknown path — a wrong-but-plausible 0 would read as a real value. */ getMixValue(path: string): number { return this.lens(path)?.read() ?? Number.NaN; } setVoiceEnabled(voice: VoiceName, on: boolean): void { this.mixState.enabled[voice] = on; } resetMix(): void { this.mixState = cloneMix(DEFAULT_MIX); this.ramp(this.master.gain, this.mixState.master, KNOB_RAMP_S, false); if (this.override === null) { this.ramp(this.filter.Q, this.qFor(this.loc), KNOB_RAMP_S, false); } } // --- night arc ------------------------------------------------------------ /** * Pin the arc to a minute for the ear pass — this is how 1 AM gets auditioned * at 9 PM. null hands the arc back to `clock:tick`. */ setArcMinute(min: number | null): void { this.pinnedMinute = min; this.scales = arcAt(this.arcMinute); } get arcMinute(): number { return this.pinnedMinute ?? this.clockMinute; } get arcScales(): ArcScales { return { ...this.scales }; } get arcPinned(): boolean { return this.pinnedMinute !== null; } /** * Resume after a real user gesture. Idempotent, and safe to call concurrently * — overlapping callers await the same resume rather than racing the origin. */ async unlock(): Promise { if (this.unlockedFlag) return; if (this.unlocking) return this.unlocking; this.unlocking = (async () => { if (this.ctx.state === 'suspended') await this.ctx.resume(); const atMs = this.ctx.currentTime * 1000; // Re-origin the grid: beat 0 is now, not whenever this object was built. this.grid.originMs = atMs + START_LEAD_MS; this.nextBeat = 0; this.unlockedFlag = true; // Review ruling: atMs carries the ANCHORED beat-0 origin, not the resume // instant — consumers may treat beat 0 as landing exactly at atMs. this.bus.emit('audio:unlocked', { atMs: this.grid.originMs }); })(); try { await this.unlocking; } finally { this.unlocking = null; } } /** * Start the scheduler. Starting before `unlock()` is legal but silent: the * timer spins and emits nothing until the gesture lands. Callers therefore * never have to sequence start/unlock. * * Restarting after a stop needs no re-anchoring here: nothing is scheduled * outside `tick()`, and its first wake resyncs past the gap. */ start(): void { if (this.timer !== null) return; this.timer = setInterval(() => this.tick(), TIMER_MS); } stop(): void { if (this.timer !== null) { clearInterval(this.timer); this.timer = null; } for (const [src, chain] of [...this.voices]) { src.onended = null; try { src.stop(); } catch { /* already finished — nothing to silence */ } this.release(src, chain); } this.voices.clear(); } /** Ramp to the location's cutoff/gain. A pinned override wins over both. */ setLocation(location: AudioLocation, immediate = false): void { this.loc = location; if (this.override !== null) return; const durS = immediate ? 0 : sweepMsFor(location) / 1000; this.ramp(this.filter.frequency, cutoffFor(location), durS, true); this.ramp(this.musicBus.gain, gainFor(location), durS, false); this.ramp(this.filter.Q, this.qFor(location), durS, false); } /** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */ setCutoffOverride(hz: number | null): void { this.override = hz; if (hz === null) { this.setLocation(this.loc); return; } this.ramp(this.filter.frequency, this.clampHz(hz), 0, true); } destroy(): void { this.stop(); for (const off of this.unsubs) off(); this.unsubs.length = 0; if (activeEngine === this) activeEngine = null; void this.ctx.close().catch(() => undefined); } // --- scheduling ----------------------------------------------------------- private tick(): void { if (!this.unlockedFlag) return; const nowMs = this.ctx.currentTime * 1000; this.resync(nowMs); const due = dueBeats(this.grid, this.nextBeat, nowMs, this.lookaheadMs); if (due.length === 0) return; for (const { beatIndex, timeMs } of due) { this.scheduleBeat(beatIndex, timeMs); // The SCHEDULED time, not the current one. Consumers time their input // against this, so a convenient lie here is a broken rhythm game. this.bus.emit('beat:tick', { beatIndex, audioTimeMs: timeMs }); if (isDownbeat(beatIndex)) { this.bus.emit('beat:bar', { barIndex: barOf(beatIndex), beatIndex, audioTimeMs: timeMs }); } } const last = due[due.length - 1]; if (last) this.nextBeat = last.beatIndex + 1; } /** * Skip a stale span rather than catch up on it. A throttled timer (hidden tab) * or a long stop/start gap leaves the cursor minutes behind the audio clock; * playing those beats would start every voice on the same sample — a clipped * blast, not music — and `beat:tick` would hand the rhythm game timestamps it * could only score as misses. The grace is one beat: ordinary wakeup jitter * must not trigger a jump. * * Landing on a downbeat rather than the nearest beat keeps bar phase, so the * resync reads as a cut instead of the track lurching sideways. */ private resync(nowMs: number): void { const interval = beatIntervalMs(this.bpm); if (beatTimeMs(this.grid, this.nextBeat) >= nowMs - interval) return; const firstFuture = Math.ceil((nowMs - this.grid.originMs) / interval); this.nextBeat = Math.ceil(firstFuture / 4) * 4; } /** * Effective gain for a voice right now: level, mute and arc folded together. * Shared with the tuning desk via mix.ts so the number on screen is the number * the scheduler uses. */ private gainOf(voice: VoiceName): number { return voiceGain(this.mixState, voice, this.scales[voice]); } private scheduleBeat(beatIndex: number, timeMs: number): void { const sixteenths = subdivisions(this.grid, beatIndex, 4); const mix = this.mixState; // Silent voices are skipped, not scheduled quietly: an exponential ramp to // zero throws, and at 9 PM the arc mutes the bass outright — no reason to // build and tear down eight nodes a second for something nobody can hear. const kickGain = this.gainOf('kick'); if (kickGain > SILENT) this.kick(timeMs / 1000, kickGain); const hatGain = this.gainOf('hat'); const offbeat = sixteenths[2]; // the 8th between beats if (hatGain > SILENT && offbeat !== undefined) this.hat(offbeat / 1000, hatGain); const bassGain = this.gainOf('bass'); if (bassGain > SILENT) { for (let s = 0; s < sixteenths.length; s++) { const at = sixteenths[s]; const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)]; if (at === undefined || semi === undefined || semi === null) continue; this.bass(at / 1000, mix.bass.rootHz * Math.pow(2, semi / 12), bassGain); } } const padGain = this.gainOf('pad'); const padBars = this.padBars(); if (padGain > SILENT && isDownbeat(beatIndex) && barOf(beatIndex) % padBars === 0) { this.pad(timeMs / 1000, padGain, padBars); } } /** The pad's cycle length doubles as its retrigger interval, so it must be a whole bar count. */ private padBars(): number { return Math.max(1, Math.round(this.mixState.pad.bars)); } // --- voices --------------------------------------------------------------- private kick(at: number, peak: number): void { const k = this.mixState.kick; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sine'; // The pitch drop is the beater; the tail is the cone. This is the one voice // that has to survive a 250Hz lowpass, so it gets a long body. osc.frequency.setValueAtTime(k.startHz, at); osc.frequency.exponentialRampToValueAtTime(k.endHz, at + k.dropS); // A decay tuned shorter than the attack would schedule the ramps out of // order; the desk allows that combination, WebAudio does not. const decayS = Math.max(k.attackS + 0.005, k.decayS); gain.gain.setValueAtTime(SILENT, at); gain.gain.exponentialRampToValueAtTime(peak, at + k.attackS); gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS); osc.connect(gain).connect(this.musicBus); osc.start(at); osc.stop(at + decayS + 0.02); this.hold(osc, [gain]); } private hat(at: number, peak: number): void { const h = this.mixState.hat; const src = this.ctx.createBufferSource(); src.buffer = this.noiseBuffer; const hp = this.ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = h.hpHz; const gain = this.ctx.createGain(); gain.gain.setValueAtTime(peak, at); gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS); src.connect(hp).connect(gain).connect(this.musicBus); // Read far enough to cover the envelope; a longer decay must not be cut off // by the buffer running out under it. const readS = Math.min(Math.max(0.06, h.decayS + 0.02), this.noiseBuffer.duration); // Cheap variety: start the read head somewhere different each hit so the one // shared buffer doesn't ring identically 400 times a minute. The span is the // buffer minus the read length, so offset + readS lands inside the buffer for // any tuning of hat.decayS; a read as long as the buffer leaves no span. const span = this.noiseBuffer.duration - readS; const offset = span > 0 ? (at * 7.3) % span : 0; src.start(at, offset, readS); this.hold(src, [hp, gain]); } private bass(at: number, hz: number, peak: number): void { const b = this.mixState.bass; const lp = this.ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = b.q; const decayS = Math.max(BASS_SWEEP_S + 0.02, b.decayS); // Per-note filter sweep — the "wow" that stops 16ths sounding like a fax. lp.frequency.setValueAtTime(b.filterLoHz, at); lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S); lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01); const gain = this.ctx.createGain(); gain.gain.setValueAtTime(SILENT, at); gain.gain.exponentialRampToValueAtTime(peak, at + 0.006); gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS); lp.connect(gain).connect(this.musicBus); const spread = [-b.detuneCents, b.detuneCents]; spread.forEach((detune, i) => { const osc = this.ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = hz; osc.detune.value = detune; osc.connect(lp); osc.start(at); osc.stop(at + decayS + 0.01); // Hang the shared chain off the LAST saw by index, not by sign: at zero // detune both are 0 and a sign test would strand lp/gain on the bus. this.hold(osc, i === spread.length - 1 ? [lp, gain] : []); }); } private pad(at: number, peak: number, bars: number): void { const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000; const lp = this.ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = this.mixState.pad.lpHz; const gain = this.ctx.createGain(); gain.gain.setValueAtTime(0, at); gain.gain.linearRampToValueAtTime(peak, at + cycle * PAD_RISE); gain.gain.linearRampToValueAtTime(0, at + cycle); lp.connect(gain).connect(this.musicBus); PAD_VOICES.forEach((v, i) => { const osc = this.ctx.createOscillator(); osc.type = v.type; osc.frequency.value = v.hz; osc.detune.value = v.detune; osc.connect(lp); osc.start(at); osc.stop(at + cycle); this.hold(osc, i === PAD_VOICES.length - 1 ? [lp, gain] : []); }); } // --- plumbing ------------------------------------------------------------- private qFor(location: AudioLocation): number { return location === 'floor' ? this.mixState.floorQ : this.mixState.doorQ; } /** Resolve a dotted mix path to a get/set/clamp triple, or null if it is not a knob. */ private lens(path: string): MixLens | null { const m = this.mixState; if (path === 'master' || path === 'doorQ' || path === 'floorQ') { const range = RANGES[path]; if (!range) return null; const key = path; return { read: () => m[key], write: (v) => { m[key] = v; }, range }; } const dot = path.indexOf('.'); if (dot <= 0) return null; const head = path.slice(0, dot); const tail = path.slice(dot + 1); if (head === 'levels') { // MIX_RANGES keys the shared bound as 'level' (singular) — one range for // all four faders — while the path is per-voice. if (!(VOICE_NAMES as readonly string[]).includes(tail)) return null; const v = tail as VoiceName; return { read: () => m.levels[v], write: (x) => { m.levels[v] = x; }, range: MIX_RANGES.level }; } if (!(GROUP_NAMES as readonly string[]).includes(head)) return null; // The four param groups have no index signature by design — the widening is // confined here, behind the hasOwn check below. const group = m[head as VoiceName] as unknown as Record; if (!Object.hasOwn(group, tail)) return null; const range = RANGES[path]; if (!range) return null; return { read: () => group[tail] ?? Number.NaN, write: (x) => { group[tail] = x; }, range }; } /** * One second of deterministic white noise, built once and re-read per hat. * Allocating a buffer per hit would mean ~8 buffers a second for six hours. */ private buildNoise(): AudioBuffer { const len = Math.floor(this.ctx.sampleRate); const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate); const data = buf.getChannelData(0); const rng = new SeededRNG(0x1a7).stream('hat-noise'); for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1; return buf; } /** Keep the node reachable for stop(); let it free itself if it runs out. */ private hold(src: AudioScheduledSourceNode, chain: AudioNode[]): void { this.voices.set(src, chain); src.onended = () => this.release(src, chain); } private release(src: AudioScheduledSourceNode, chain: AudioNode[]): void { this.voices.delete(src); src.disconnect(); for (const n of chain) n.disconnect(); } /** * Re-anchor at the live value before ramping so a sweep interrupted mid-flight * (door -> floor -> door in two seconds, which players absolutely do) continues * from where it actually is instead of snapping. * * Exponential ramps trace exactly `cutoffAt()`'s curve — v0*(v1/v0)^t — so we * get cutoffCurve()'s shape without setValueCurveAtTime's rule that a new curve * may not overlap a running one. That overlap throws, and re-entrant location * changes are the normal case here, not the edge case. */ private ramp(param: AudioParam, to: number, durS: number, exponential: boolean): void { const now = this.ctx.currentTime; const from = param.value; param.cancelScheduledValues(now); param.setValueAtTime(from, now); if (durS <= 0) { param.setValueAtTime(to, now); } else if (exponential && from > 0 && to > 0) { param.exponentialRampToValueAtTime(to, now + durS); } else { param.linearRampToValueAtTime(to, now + durS); } } private clampHz(hz: number): number { return Math.max(20, Math.min(hz, this.ctx.sampleRate / 2)); } }