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>
671 lines
24 KiB
TypeScript
671 lines
24 KiB
TypeScript
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<string, readonly [number, number] | undefined> = 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<AudioScheduledSourceNode, AudioNode[]>();
|
|
|
|
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<typeof setInterval> | null = null;
|
|
private nextBeat = 0;
|
|
private loc: AudioLocation = 'door';
|
|
private override: number | null = null;
|
|
private unlockedFlag = false;
|
|
private unlocking: Promise<void> | 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<TrackMix> {
|
|
return this.mixState;
|
|
}
|
|
|
|
/**
|
|
* Set one knob by dotted path: `'master'`, `'levels.<voice>'`,
|
|
* `'<voice>.<field>'`, `'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<void> {
|
|
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<string, number>;
|
|
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));
|
|
}
|
|
}
|