TURNCRAFT/src/audio/sfx.ts
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest),
E (audio/fx/ui) as landed, each with HANDOFF.md + update docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:50:56 +10:00

205 lines
8.2 KiB
TypeScript

// 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<SoundCat, { type: BiquadFilterType; freq: number; q: number; dur: number; gain: number }> = {
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);
}
export type SfxName =
| 'footstep' | 'land' | 'break' | 'place'
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle';