// TURNCRAFT — Lane E. One-shot diegetic SFX, all synthesized and short. // Each function synthesizes into `dest` (AudioEngine routes `dest` through a // PannerNode when a world position is supplied). None of these hold state. import type { BlockDef } from '../core/blocks'; type SoundCat = BlockDef['sound']; // 'metal'|'wood'|'plastic'|'soft'|'glass'|'none' /** Short filtered noise burst, body tuned per surface category. */ function noiseHit( ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number, o: { type: BiquadFilterType; freq: number; q: number; dur: number; gain: number }, ): void { const src = ctx.createBufferSource(); src.buffer = noise; src.loop = true; const f = ctx.createBiquadFilter(); f.type = o.type; f.frequency.value = o.freq; f.Q.value = o.q; const g = ctx.createGain(); g.gain.setValueAtTime(o.gain, t); g.gain.exponentialRampToValueAtTime(0.0001, t + o.dur); src.connect(f); f.connect(g); g.connect(dest); src.start(t); src.stop(t + o.dur + 0.02); } const STEP_BY_CAT: Record = { metal: { type: 'bandpass', freq: 3200, q: 3, dur: 0.06, gain: 0.18 }, wood: { type: 'lowpass', freq: 900, q: 1, dur: 0.07, gain: 0.22 }, plastic: { type: 'highpass', freq: 1800, q: 0.7, dur: 0.045, gain: 0.16 }, soft: { type: 'lowpass', freq: 420, q: 0.7, dur: 0.09, gain: 0.20 }, glass: { type: 'bandpass', freq: 6000, q: 6, dur: 0.05, gain: 0.14 }, none: { type: 'lowpass', freq: 700, q: 1, dur: 0.05, gain: 0.10 }, }; export function footstep( ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number, cat: SoundCat, ): void { const p = STEP_BY_CAT[cat] ?? STEP_BY_CAT.none; noiseHit(ctx, dest, noise, t, p); } /** Land thump: low body scaled by impact speed + a surface tick. */ export function land( ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number, impactSpeed: number, cat: SoundCat, ): void { const amt = Math.min(1, impactSpeed / 12); const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.25 + 0.5 * amt, t + 0.005); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12 + 0.1 * amt); g.connect(dest); const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(120 + 60 * amt, t); osc.frequency.exponentialRampToValueAtTime(50, t + 0.12); osc.connect(g); osc.start(t); osc.stop(t + 0.24); const p = STEP_BY_CAT[cat] ?? STEP_BY_CAT.none; noiseHit(ctx, dest, noise, t, { ...p, gain: p.gain * (0.6 + amt) }); } /** Break/place click: brighter for break, softer for place, category-flavoured. */ export function blockHit( ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number, cat: SoundCat, kind: 'break' | 'place', ): void { const p = STEP_BY_CAT[cat] ?? STEP_BY_CAT.none; const dur = kind === 'break' ? 0.09 : 0.05; noiseHit(ctx, dest, noise, t, { ...p, dur, gain: p.gain * (kind === 'break' ? 1.4 : 0.9) }); if (kind === 'break') { // little pitch-down thock so breaks feel chunkier const g = ctx.createGain(); g.gain.setValueAtTime(0.14, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.08); g.connect(dest); const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(320, t); osc.frequency.exponentialRampToValueAtTime(120, t + 0.08); osc.connect(g); osc.start(t); osc.stop(t + 0.1); } } /** Fader zip: filtered noise sweep, quick. */ export function faderZip(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { const src = ctx.createBufferSource(); src.buffer = noise; src.loop = true; const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 4; bp.frequency.setValueAtTime(1200, t); bp.frequency.exponentialRampToValueAtTime(3600, t + 0.12); const g = ctx.createGain(); g.gain.setValueAtTime(0.12, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.13); src.connect(bp); bp.connect(g); g.connect(dest); src.start(t); src.stop(t + 0.15); } /** Button clunk: short low thock with a click. */ export function buttonClunk(ctx: BaseAudioContext, dest: AudioNode, t: number): void { const g = ctx.createGain(); g.gain.setValueAtTime(0.3, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.09); g.connect(dest); const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(220, t); osc.frequency.exponentialRampToValueAtTime(90, t + 0.08); osc.connect(g); osc.start(t); osc.stop(t + 0.1); } /** RCA plug seating: "clunk-clunk-CLICK", three rising transients. */ export function rcaClick(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { const steps = [ { off: 0.0, f: 140, g: 0.22 }, { off: 0.12, f: 200, g: 0.26 }, { off: 0.26, f: 340, g: 0.34 }, ]; for (const s of steps) { const g = ctx.createGain(); g.gain.setValueAtTime(s.g, t + s.off); g.gain.exponentialRampToValueAtTime(0.0001, t + s.off + 0.06); g.connect(dest); const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(s.f, t + s.off); osc.frequency.exponentialRampToValueAtTime(s.f * 0.6, t + s.off + 0.05); osc.connect(g); osc.start(t + s.off); osc.stop(t + s.off + 0.08); } noiseHit(ctx, dest, noise, t + 0.26, { type: 'highpass', freq: 4000, q: 2, dur: 0.04, gain: 0.18 }); } /** Fuse zap: buzzy electric discharge. */ export function fuseZap(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { const src = ctx.createBufferSource(); src.buffer = noise; src.loop = true; const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2200; bp.Q.value = 8; const g = ctx.createGain(); g.gain.setValueAtTime(0.28, t); g.gain.setValueAtTime(0.05, t + 0.02); g.gain.setValueAtTime(0.24, t + 0.05); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); src.connect(bp); bp.connect(g); g.connect(dest); src.start(t); src.stop(t + 0.24); // buzzy saw underneath const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(320, t); osc.frequency.exponentialRampToValueAtTime(80, t + 0.2); const og = ctx.createGain(); og.gain.setValueAtTime(0.12, t); og.gain.exponentialRampToValueAtTime(0.0001, t + 0.2); osc.connect(og); og.connect(dest); osc.start(t); osc.stop(t + 0.22); } /** Tonearm cue lever creak: slow filtered-noise groan. */ export function cueCreak(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { const src = ctx.createBufferSource(); src.buffer = noise; src.loop = true; const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 12; bp.frequency.setValueAtTime(420, t); bp.frequency.linearRampToValueAtTime(280, t + 0.3); const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(0.1, t + 0.06); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.34); src.connect(bp); bp.connect(g); g.connect(dest); src.start(t); src.stop(t + 0.36); } /** Needle drop: crackle burst as the stylus meets the groove (used at win). */ export function needleDrop(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { const src = ctx.createBufferSource(); src.buffer = noise; src.loop = true; const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(0.35, t + 0.01); g.gain.exponentialRampToValueAtTime(0.04, t + 0.18); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.5); const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1500; src.connect(hp); hp.connect(g); g.connect(dest); src.start(t); src.stop(t + 0.55); // the "thunk" of the tonearm landing const tg = ctx.createGain(); tg.gain.setValueAtTime(0.3, t); tg.gain.exponentialRampToValueAtTime(0.0001, t + 0.12); tg.connect(dest); const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(90, t); osc.frequency.exponentialRampToValueAtTime(45, t + 0.1); osc.connect(tg); osc.start(t); osc.stop(t + 0.14); } // ── Headshell Workshop SFX (WORKSHOP_CARTRIDGE.md) ────────────────────────── /** Crimping a tag-wire onto a pin: a stubborn ratchet tick (call while holding). */ export function crimp(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { noiseHit(ctx, dest, noise, t, { type: 'bandpass', freq: 2600, q: 8, dur: 0.03, gain: 0.12 }); const g = ctx.createGain(); g.gain.setValueAtTime(0.08, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.04); g.connect(dest); const o = ctx.createOscillator(); o.type = 'square'; o.frequency.setValueAtTime(180, t); o.frequency.exponentialRampToValueAtTime(320, t + 0.03); o.connect(g); o.start(t); o.stop(t + 0.05); } /** Wire seats home: a crisp little snap. */ export function wireOn(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { noiseHit(ctx, dest, noise, t, { type: 'highpass', freq: 4200, q: 2, dur: 0.035, gain: 0.2 }); const g = ctx.createGain(); g.gain.setValueAtTime(0.18, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.05); g.connect(dest); const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.setValueAtTime(520, t); o.frequency.exponentialRampToValueAtTime(760, t + 0.04); o.connect(g); o.start(t); o.stop(t + 0.06); } /** Wire pulled off a pin: a soft dull pop. */ export function wireOff(ctx: BaseAudioContext, dest: AudioNode, t: number): void { const g = ctx.createGain(); g.gain.setValueAtTime(0.16, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07); g.connect(dest); const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(300, t); o.frequency.exponentialRampToValueAtTime(140, t + 0.06); o.connect(g); o.start(t); o.stop(t + 0.08); } /** One click of the screwdriver ratchet (call repeatedly while torquing). */ export function screwTick(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { noiseHit(ctx, dest, noise, t, { type: 'bandpass', freq: 3400, q: 10, dur: 0.02, gain: 0.09 }); } /** Both screws seat evenly: a satisfying two-tone CLUNK. */ export function screwClunk(ctx: BaseAudioContext, dest: AudioNode, t: number): void { for (const [f0, f1, off, gain] of [[240, 90, 0, 0.32], [420, 160, 0.02, 0.18]] as const) { const g = ctx.createGain(); g.gain.setValueAtTime(gain, t + off); g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.12); g.connect(dest); const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(f0, t + off); o.frequency.exponentialRampToValueAtTime(f1, t + off + 0.1); o.connect(g); o.start(t + off); o.stop(t + off + 0.14); } } /** The cartridge tips under uneven torque: a low groaning creak. */ export function tiltCreak(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { const src = ctx.createBufferSource(); src.buffer = noise; src.loop = true; const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 16; bp.frequency.setValueAtTime(340, t); bp.frequency.linearRampToValueAtTime(220, t + 0.35); const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(0.11, t + 0.08); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.4); src.connect(bp); bp.connect(g); g.connect(dest); src.start(t); src.stop(t + 0.42); } /** Counterweight steps a notch along the rail: a mechanical detent tick. */ export function weightDetent(ctx: BaseAudioContext, dest: AudioNode, t: number): void { const g = ctx.createGain(); g.gain.setValueAtTime(0.16, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.045); g.connect(dest); const o = ctx.createOscillator(); o.type = 'square'; o.frequency.setValueAtTime(430, t); o.frequency.exponentialRampToValueAtTime(210, t + 0.04); o.connect(g); o.start(t); o.stop(t + 0.055); } /** Bubble level centers on the 2 g mark: a soft confirming two-note chime. */ export function bubbleLock(ctx: BaseAudioContext, dest: AudioNode, t: number): void { for (const [f, off] of [[880, 0], [1320, 0.08]] as const) { const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t + off); g.gain.exponentialRampToValueAtTime(0.12, t + off + 0.01); g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.3); g.connect(dest); const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f; o.connect(g); o.start(t + off); o.stop(t + off + 0.34); } } /** The needle skates when the cartridge is rotated wrong: descending zip. */ export function skate(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { const src = ctx.createBufferSource(); src.buffer = noise; src.loop = true; const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 6; bp.frequency.setValueAtTime(5200, t); bp.frequency.exponentialRampToValueAtTime(400, t + 0.6); const g = ctx.createGain(); g.gain.setValueAtTime(0.26, t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.7); src.connect(bp); bp.connect(g); g.connect(dest); src.start(t); src.stop(t + 0.75); } /** * The classic rave airhorn (SOCIAL_RESET emote V): three detuned saws through a * resonant bandpass, with the signature pitch droop on the tail. Rate-limited by * the caller — 1 per 4 s per peer, client AND relay — or the booth becomes hell. */ export function airhorn(ctx: BaseAudioContext, dest: AudioNode, t: number): void { const dur = 0.85; const out = ctx.createGain(); out.gain.setValueAtTime(0.0001, t); out.gain.exponentialRampToValueAtTime(0.22, t + 0.03); // fast attack out.gain.setValueAtTime(0.22, t + dur - 0.22); out.gain.exponentialRampToValueAtTime(0.0001, t + dur); // Resonant horn body const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1400; bp.Q.value = 3.5; const shaper = ctx.createWaveShaper(); const curve = new Float32Array(257); for (let i = 0; i < 257; i++) { const x = (i / 128) - 1; curve[i] = Math.tanh(x * 2.6); // a bit of grit } shaper.curve = curve; bp.connect(shaper); shaper.connect(out); out.connect(dest); // Three saws, detuned; each droops ~12% at the end (the "hoooonk-eugh") for (const [mult, det, gain] of [[1, 0, 0.5], [1, 7, 0.34], [2, -5, 0.2]] as const) { const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.detune.value = det; const f0 = 233 * mult; // ~Bb3 osc.frequency.setValueAtTime(f0, t); osc.frequency.setValueAtTime(f0, t + dur - 0.3); osc.frequency.exponentialRampToValueAtTime(f0 * 0.86, t + dur); // droop const g = ctx.createGain(); g.gain.value = gain; osc.connect(g); g.connect(bp); osc.start(t); osc.stop(t + dur + 0.02); } } /** * Blow-the-fuse power-down (SOCIAL_RESET reset ritual): the mains-thunk half of * the moment. The stems' pitch brake lives in AudioEngine (it owns the * transport); this is the transformer clunk + dying hum underneath it. */ export function powerDown(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void { // Contactor thunk const thunk = ctx.createOscillator(); thunk.type = 'sine'; thunk.frequency.setValueAtTime(90, t); thunk.frequency.exponentialRampToValueAtTime(28, t + 0.18); const tg = ctx.createGain(); tg.gain.setValueAtTime(0.5, t); tg.gain.exponentialRampToValueAtTime(0.0001, t + 0.3); thunk.connect(tg); tg.connect(dest); thunk.start(t); thunk.stop(t + 0.32); // 50 Hz hum sagging away as the rails collapse const hum = ctx.createOscillator(); hum.type = 'sawtooth'; hum.frequency.setValueAtTime(50, t); hum.frequency.exponentialRampToValueAtTime(14, t + 1.6); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(1200, t); lp.frequency.exponentialRampToValueAtTime(90, t + 1.6); const hg = ctx.createGain(); hg.gain.setValueAtTime(0.14, t); hg.gain.exponentialRampToValueAtTime(0.0001, t + 1.7); hum.connect(lp); lp.connect(hg); hg.connect(dest); hum.start(t); hum.stop(t + 1.72); // Spark crackle off the blown fuse const src = ctx.createBufferSource(); src.buffer = noise; src.loop = true; const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 3200; bp.Q.value = 6; const ng = ctx.createGain(); ng.gain.setValueAtTime(0.2, t); ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.25); src.connect(bp); bp.connect(ng); ng.connect(dest); src.start(t); src.stop(t + 0.27); } export type SfxName = | 'footstep' | 'land' | 'break' | 'place' | 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle' // Headshell Workshop: | 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk' | 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate' // Social & Reset Ritual: | 'airhorn' | 'powerDown';