Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.
- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
(default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
feedback-echo + spring, disco pentatonic tiles) over a boothGain
duck-and-handback that can never restore stale state; portal/stamp/golden
SFX; bonus stem.
Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.
Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
// TURNCRAFT — Lane E. Low-level voice synthesis.
|
|
// Pure helpers: each creates + starts the Web Audio nodes for a single voice at
|
|
// an absolute AudioContext time `t`, routed into `dest`, then auto-stops. No
|
|
// shared mutable state, so they are safe to call from the lookahead scheduler.
|
|
//
|
|
// `detune` (cents) is applied to every pitched source so the turntable
|
|
// spin-up/brake (which detunes the whole mix) bends drums, bass and synths
|
|
// together. Noise sources honour it too via their `.detune` AudioParam.
|
|
|
|
/** MIDI note number -> frequency in Hz (A4 = 69 = 440 Hz). */
|
|
export function mtof(midi: number): number {
|
|
return 440 * Math.pow(2, (midi - 69) / 12);
|
|
}
|
|
|
|
/** A few seconds of white noise, generated once and looped by noise voices. */
|
|
export function createNoiseBuffer(ctx: BaseAudioContext, seconds = 2): AudioBuffer {
|
|
const len = Math.floor(ctx.sampleRate * seconds);
|
|
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
|
const data = buf.getChannelData(0);
|
|
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
|
return buf;
|
|
}
|
|
|
|
/**
|
|
* Sparse vinyl crackle: faint hiss plus randomly placed pops. Looped at a low
|
|
* level under the mix when the record plays ("vinyl truth").
|
|
*/
|
|
export function createCrackleBuffer(ctx: BaseAudioContext, seconds = 4): AudioBuffer {
|
|
const len = Math.floor(ctx.sampleRate * seconds);
|
|
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
|
const data = buf.getChannelData(0);
|
|
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * 0.06; // hiss bed
|
|
// scatter pops: short decaying impulses
|
|
const pops = Math.floor(seconds * 24);
|
|
for (let p = 0; p < pops; p++) {
|
|
const start = Math.floor(Math.random() * (len - 400));
|
|
const amp = 0.3 + Math.random() * 0.6;
|
|
const dur = 20 + Math.floor(Math.random() * 180);
|
|
for (let i = 0; i < dur; i++) {
|
|
const env = 1 - i / dur;
|
|
data[start + i] += (Math.random() * 2 - 1) * amp * env * env;
|
|
}
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
// ── Drum voices ───────────────────────────────────────────────────────────
|
|
|
|
/** 4-on-the-floor kick: pitch-drop sine body + a short click transient. */
|
|
export function kick(
|
|
ctx: BaseAudioContext, dest: AudioNode, t: number,
|
|
o: { gain?: number; detune?: number } = {},
|
|
): void {
|
|
const gain = o.gain ?? 1;
|
|
const rate = Math.pow(2, (o.detune ?? 0) / 1200);
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.004);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.34);
|
|
g.connect(dest);
|
|
|
|
const osc = ctx.createOscillator();
|
|
osc.type = 'sine';
|
|
osc.frequency.setValueAtTime(150 * rate, t);
|
|
osc.frequency.exponentialRampToValueAtTime(48 * rate, t + 0.12);
|
|
osc.connect(g);
|
|
osc.start(t);
|
|
osc.stop(t + 0.36);
|
|
|
|
// click transient
|
|
const cg = ctx.createGain();
|
|
cg.gain.setValueAtTime(0.5 * gain, t);
|
|
cg.gain.exponentialRampToValueAtTime(0.0001, t + 0.02);
|
|
cg.connect(dest);
|
|
const co = ctx.createOscillator();
|
|
co.type = 'triangle';
|
|
co.frequency.setValueAtTime(1200 * rate, t);
|
|
co.connect(cg);
|
|
co.start(t);
|
|
co.stop(t + 0.03);
|
|
}
|
|
|
|
/** Closed hat: highpassed noise burst, very short. */
|
|
export function hat(
|
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
|
o: { gain?: number; detune?: number; open?: boolean } = {},
|
|
): void {
|
|
const gain = o.gain ?? 0.3;
|
|
const dur = o.open ? 0.18 : 0.045;
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = noise;
|
|
src.loop = true;
|
|
src.detune.value = o.detune ?? 0;
|
|
const hp = ctx.createBiquadFilter();
|
|
hp.type = 'highpass';
|
|
hp.frequency.value = 7500;
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(gain, t);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
src.connect(hp); hp.connect(g); g.connect(dest);
|
|
src.start(t);
|
|
src.stop(t + dur + 0.02);
|
|
}
|
|
|
|
/** Clap: a few tight noise bursts through a bandpass for the classic texture. */
|
|
export function clap(
|
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
|
o: { gain?: number; detune?: number } = {},
|
|
): void {
|
|
const gain = o.gain ?? 0.5;
|
|
const bp = ctx.createBiquadFilter();
|
|
bp.type = 'bandpass';
|
|
bp.frequency.value = 1500;
|
|
bp.Q.value = 1.2;
|
|
const g = ctx.createGain();
|
|
g.connect(dest);
|
|
bp.connect(g);
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = noise;
|
|
src.loop = true;
|
|
src.detune.value = o.detune ?? 0;
|
|
src.connect(bp);
|
|
// three fast pre-claps + a longer body
|
|
const offs = [0, 0.011, 0.022, 0.033];
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
for (const off of offs) {
|
|
g.gain.setValueAtTime(gain, t + off);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.02);
|
|
}
|
|
g.gain.setValueAtTime(gain * 0.9, t + 0.033);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.18);
|
|
src.start(t);
|
|
src.stop(t + 0.22);
|
|
}
|
|
|
|
// ── Tonal voices ────────────────────────────────────────────────────────────
|
|
|
|
/** Bass: detuned saws through a lowpass, plucky envelope. Route via sidechain. */
|
|
export function bass(
|
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
|
o: { gain?: number; detune?: number; dur?: number } = {},
|
|
): void {
|
|
const gain = o.gain ?? 0.5;
|
|
const dur = o.dur ?? 0.22;
|
|
const det = o.detune ?? 0;
|
|
const lp = ctx.createBiquadFilter();
|
|
lp.type = 'lowpass';
|
|
lp.frequency.setValueAtTime(520, t);
|
|
lp.frequency.exponentialRampToValueAtTime(180, t + dur);
|
|
lp.Q.value = 6;
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.012);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
lp.connect(g); g.connect(dest);
|
|
for (const cents of [-6, 7]) {
|
|
const osc = ctx.createOscillator();
|
|
osc.type = 'sawtooth';
|
|
osc.frequency.value = freq;
|
|
osc.detune.value = det + cents;
|
|
osc.connect(lp);
|
|
osc.start(t);
|
|
osc.stop(t + dur + 0.05);
|
|
}
|
|
// sub sine for weight
|
|
const sub = ctx.createOscillator();
|
|
sub.type = 'sine';
|
|
sub.frequency.value = freq;
|
|
sub.detune.value = det;
|
|
const sg = ctx.createGain();
|
|
sg.gain.setValueAtTime(0.0001, t);
|
|
sg.gain.exponentialRampToValueAtTime(gain * 0.8, t + 0.012);
|
|
sg.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
sub.connect(sg); sg.connect(dest);
|
|
sub.start(t);
|
|
sub.stop(t + dur + 0.05);
|
|
}
|
|
|
|
/** Chord stab: multiple saws (a chord) through a resonant lowpass, short. */
|
|
export function stab(
|
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freqs: number[],
|
|
o: { gain?: number; detune?: number; dur?: number } = {},
|
|
): void {
|
|
const gain = (o.gain ?? 0.28) / Math.max(1, freqs.length);
|
|
const dur = o.dur ?? 0.16;
|
|
const det = o.detune ?? 0;
|
|
const lp = ctx.createBiquadFilter();
|
|
lp.type = 'lowpass';
|
|
lp.frequency.setValueAtTime(2600, t);
|
|
lp.frequency.exponentialRampToValueAtTime(700, t + dur);
|
|
lp.Q.value = 4;
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.006);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
lp.connect(g); g.connect(dest);
|
|
for (const f of freqs) {
|
|
const osc = ctx.createOscillator();
|
|
osc.type = 'sawtooth';
|
|
osc.frequency.value = f;
|
|
osc.detune.value = det;
|
|
osc.connect(lp);
|
|
osc.start(t);
|
|
osc.stop(t + dur + 0.05);
|
|
}
|
|
}
|
|
|
|
/** Lead: a fat square/sine blend with a long release for the sparse hook. */
|
|
export function lead(
|
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
|
o: { gain?: number; detune?: number; dur?: number } = {},
|
|
): void {
|
|
const gain = o.gain ?? 0.24;
|
|
const dur = o.dur ?? 0.5;
|
|
const det = o.detune ?? 0;
|
|
const lp = ctx.createBiquadFilter();
|
|
lp.type = 'lowpass';
|
|
lp.frequency.value = 3200;
|
|
lp.Q.value = 1;
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.02);
|
|
g.gain.setValueAtTime(gain, t + dur * 0.4);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
lp.connect(g); g.connect(dest);
|
|
const sq = ctx.createOscillator();
|
|
sq.type = 'square';
|
|
sq.frequency.value = freq;
|
|
sq.detune.value = det;
|
|
sq.connect(lp);
|
|
sq.start(t); sq.stop(t + dur + 0.05);
|
|
const si = ctx.createOscillator();
|
|
si.type = 'sine';
|
|
si.frequency.value = freq * 2;
|
|
si.detune.value = det;
|
|
const sig = ctx.createGain();
|
|
sig.gain.value = 0.4;
|
|
si.connect(sig); sig.connect(lp);
|
|
si.start(t); si.stop(t + dur + 0.05);
|
|
}
|
|
|
|
/** Shaker: a breathy high bandpass chiff. 16th-note bed of the bonus stem. */
|
|
export function shaker(
|
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
|
o: { gain?: number; detune?: number } = {},
|
|
): void {
|
|
const gain = o.gain ?? 0.1;
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = noise; src.loop = true;
|
|
src.detune.value = o.detune ?? 0;
|
|
const bp = ctx.createBiquadFilter();
|
|
bp.type = 'bandpass'; bp.frequency.value = 5200; bp.Q.value = 1.4;
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.012);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07);
|
|
src.connect(bp); bp.connect(g); g.connect(dest);
|
|
src.start(t); src.stop(t + 0.09);
|
|
}
|
|
|
|
/** Conga: pitched hand-drum. `slap` = tighter/brighter with a skin tick. */
|
|
export function conga(
|
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
|
o: { gain?: number; detune?: number; slap?: boolean } = {},
|
|
): void {
|
|
const gain = o.gain ?? 0.24;
|
|
const rate = Math.pow(2, (o.detune ?? 0) / 1200);
|
|
const f0 = (o.slap ? 300 : 205) * rate;
|
|
const dur = o.slap ? 0.09 : 0.16;
|
|
const g = ctx.createGain();
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.005);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
g.connect(dest);
|
|
const osc = ctx.createOscillator();
|
|
osc.type = 'sine';
|
|
osc.frequency.setValueAtTime(f0, t);
|
|
osc.frequency.exponentialRampToValueAtTime(f0 * 0.72, t + dur);
|
|
osc.connect(g);
|
|
osc.start(t); osc.stop(t + dur + 0.03);
|
|
if (o.slap) {
|
|
// skin tick on the slap
|
|
const sg = ctx.createGain();
|
|
sg.gain.setValueAtTime(gain * 0.5, t);
|
|
sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.03);
|
|
sg.connect(dest);
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = noise; src.loop = true;
|
|
const hp = ctx.createBiquadFilter();
|
|
hp.type = 'highpass'; hp.frequency.value = 2600;
|
|
src.connect(hp); hp.connect(sg);
|
|
src.start(t); src.stop(t + 0.04);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* White-noise sweep (riser when up=true, downlifter when up=false) through a
|
|
* moving bandpass. Used at 8-bar boundaries; also the win-open sweep.
|
|
*/
|
|
export function sweep(
|
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
|
o: { up?: boolean; gain?: number; dur?: number } = {},
|
|
): void {
|
|
const up = o.up ?? true;
|
|
const gain = o.gain ?? 0.22;
|
|
const dur = o.dur ?? 1.8;
|
|
const src = ctx.createBufferSource();
|
|
src.buffer = noise;
|
|
src.loop = true;
|
|
const bp = ctx.createBiquadFilter();
|
|
bp.type = 'bandpass';
|
|
bp.Q.value = 0.8;
|
|
bp.frequency.setValueAtTime(up ? 300 : 6000, t);
|
|
bp.frequency.exponentialRampToValueAtTime(up ? 6000 : 250, t + dur);
|
|
const g = ctx.createGain();
|
|
if (up) {
|
|
g.gain.setValueAtTime(0.0001, t);
|
|
g.gain.exponentialRampToValueAtTime(gain, t + dur * 0.9);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
} else {
|
|
g.gain.setValueAtTime(gain, t);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
|
}
|
|
src.connect(bp); bp.connect(g); g.connect(dest);
|
|
src.start(t);
|
|
src.stop(t + dur + 0.05);
|
|
}
|