TURNCRAFT/src/audio/synth.ts
type-two 33232fdeba SIDE B: the crate worlds — three dive-in pockets, stamps, golden slipmat
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>
2026-08-16 16:22:11 +10:00

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);
}