From 66ac52103737d8cd3f9976be5945badb267cd048 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 6 Aug 2026 12:29:24 +0000 Subject: [PATCH] Redesign SFX to actually sound cyberpunk, not just plain blips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous version was a single oscillator sweep per sound — thin and generic. Rebuilt with the layering that actually reads as sci-fi/digital: - Filtered white-noise bursts (bandpass/highpass/lowpass sweeps) under most sounds, the 'digital texture' that plain tones don't have on their own. - A soft-clip WaveShaperNode distortion curve for grit on the harsher sounds (link zap, error). - Detuned dual-oscillator layers for thickness (claim, field). - Claim: two detuned rising saws through an opening lowpass filter, a rising noise sweep underneath, bright ping on top. - Link: fast descending saw through heavy grit, sharp noise crack. - Field: detuned arpeggio with a per-note opening filter sweep, a noise swell, and a sub-bass hit landing on the final note. - Error: two harsh low blips through heavy grit plus a noise crackle (was a single smooth sweep). - Click: tightened to a crisp tick with a short high noise transient. --- frontend/src/lib/audio.ts | 145 ++++++++++++++++++++++++++++++++------ 1 file changed, 122 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/audio.ts b/frontend/src/lib/audio.ts index 31cba20..44d4f61 100644 --- a/frontend/src/lib/audio.ts +++ b/frontend/src/lib/audio.ts @@ -1,7 +1,7 @@ // Web Audio sound engine — synthesized SFX + a generative ambient music -// loop, all generated on the fly (oscillators/filters), no audio files. -// Real licensed music/SFX isn't something to source without the rights to -// it, and synthesis fits the HUD aesthetic anyway. +// loop, all generated on the fly (oscillators/filters/noise/distortion), no +// audio files. Real licensed music/SFX isn't something to source without +// the rights to it, and synthesis fits the HUD aesthetic anyway. // // Browsers block audio before a user gesture, so the AudioContext is // created lazily on first interaction (see ensureContext()) rather than at @@ -13,6 +13,7 @@ let musicGain: GainNode | null = null; let sfxGain: GainNode | null = null; let musicTimer: ReturnType | null = null; let musicPlaying = false; +let noiseBuffer: AudioBuffer | null = null; const MUTE_KEY = 'regress_muted'; @@ -32,8 +33,15 @@ function ensureContext(): AudioContext { musicGain.connect(masterGain); sfxGain = ctx.createGain(); - sfxGain.gain.value = 0.35; + sfxGain.gain.value = 0.4; sfxGain.connect(masterGain); + + // 1s of white noise, reused (sliced by playback duration) for every + // noise burst rather than regenerated each time. + const len = ctx.sampleRate; + noiseBuffer = ctx.createBuffer(1, len, ctx.sampleRate); + const data = noiseBuffer.getChannelData(0); + for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1; } if (ctx.state === 'suspended') void ctx.resume(); return ctx; @@ -53,52 +61,143 @@ function envelope(gain: GainNode, ac: AudioContext, attack: number, peak: number gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + release); } -function tone( +/** Soft-clip "digital grit" curve — cheap way to make a tone feel synthetic/harsh rather than clean. */ +function distortionCurve(amount: number): Float32Array { + const n = 2048; + const curve = new Float32Array(new ArrayBuffer(n * 4)); + for (let i = 0; i < n; i++) { + const x = (i * 2) / n - 1; + curve[i] = ((3 + amount) * x * 20 * (Math.PI / 180)) / (Math.PI + amount * Math.abs(x)); + } + return curve; +} + +function distortion(ac: AudioContext, amount: number): WaveShaperNode { + const shaper = ac.createWaveShaper(); + shaper.curve = distortionCurve(amount); + shaper.oversample = '2x'; + return shaper; +} + +/** A short, filtered burst of white noise — the "digital texture" under most cyberpunk UI sounds. */ +function noiseBurst( + ac: AudioContext, + filterType: BiquadFilterType, freqStart: number, freqEnd: number, + q: number, duration: number, - type: OscillatorType, peak: number, delay = 0, ) { + if (!noiseBuffer) return; + const src = ac.createBufferSource(); + src.buffer = noiseBuffer; + const filter = ac.createBiquadFilter(); + filter.type = filterType; + filter.Q.value = q; + const t = ac.currentTime + delay; + filter.frequency.setValueAtTime(freqStart, t); + filter.frequency.exponentialRampToValueAtTime(Math.max(freqEnd, 40), t + duration); + const gain = ac.createGain(); + envelope(gain, ac, duration * 0.1, peak, duration * 0.9, delay); + src.connect(filter); + filter.connect(gain); + gain.connect(sfxGain!); + src.start(t); + src.stop(t + duration + 0.05); +} + +interface ToneOpts { + type?: OscillatorType; + detune?: number; + filterFrom?: number; + filterTo?: number; + grit?: number; // 0 = clean, higher = harsher digital distortion +} + +/** A pitch-swept oscillator, optionally lowpass-swept and/or distorted for a synthetic edge. */ +function tone(freqStart: number, freqEnd: number, duration: number, peak: number, delay = 0, opts: ToneOpts = {}) { if (isMuted()) return; const ac = ensureContext(); + const t = ac.currentTime + delay; const osc = ac.createOscillator(); + osc.type = opts.type ?? 'square'; + if (opts.detune) osc.detune.value = opts.detune; + osc.frequency.setValueAtTime(freqStart, t); + osc.frequency.exponentialRampToValueAtTime(Math.max(freqEnd, 1), t + duration); + const gain = ac.createGain(); - osc.type = type; - osc.frequency.setValueAtTime(freqStart, ac.currentTime + delay); - osc.frequency.exponentialRampToValueAtTime(Math.max(freqEnd, 1), ac.currentTime + delay + duration); - envelope(gain, ac, duration * 0.15, peak, duration * 0.85, delay); - osc.connect(gain); + envelope(gain, ac, duration * 0.12, peak, duration * 0.88, delay); + + let node: AudioNode = osc; + if (opts.filterFrom != null) { + const filter = ac.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(opts.filterFrom, t); + filter.frequency.exponentialRampToValueAtTime(Math.max(opts.filterTo ?? opts.filterFrom, 40), t + duration); + node.connect(filter); + node = filter; + } + if (opts.grit) { + const shaper = distortion(ac, opts.grit); + node.connect(shaper); + node = shaper; + } + node.connect(gain); gain.connect(sfxGain!); - osc.start(ac.currentTime + delay); - osc.stop(ac.currentTime + delay + duration + 0.05); + osc.start(t); + osc.stop(t + duration + 0.05); } -/** Rising power-up blip — a place changes hands to you. */ +/** Power-up: two detuned rising saws sweeping through an opening filter, plus a rising noise sweep underneath. */ export function playClaim(): void { - tone(280, 900, 0.18, 'square', 0.25); + if (isMuted()) return; + const ac = ensureContext(); + [0, -8].forEach((detune) => + tone(220, 1100, 0.22, 0.22, 0, { type: 'sawtooth', detune, filterFrom: 500, filterTo: 4500 }), + ); + noiseBurst(ac, 'bandpass', 400, 3000, 6, 0.22, 0.15); + tone(1400, 1800, 0.08, 0.18, 0.2, { type: 'sine' }); // bright confirmation ping on top } -/** Quick descending zap — a link forms. */ +/** Laser zap: fast descending saw through digital grit, with a sharp noise crack at the start. */ export function playLink(): void { - tone(1200, 300, 0.12, 'sawtooth', 0.2); + if (isMuted()) return; + const ac = ensureContext(); + tone(1600, 250, 0.14, 0.28, 0, { type: 'sawtooth', grit: 14 }); + noiseBurst(ac, 'highpass', 6000, 2000, 4, 0.05, 0.2); } -/** Ascending triumphant arpeggio — a field closes. */ +/** Field closes: layered detuned arpeggio with an opening filter sweep and a sub-bass hit landing on the last note. */ export function playField(): void { + if (isMuted()) return; + const ac = ensureContext(); const notes = [440, 554.37, 659.25, 880]; // A major-ish arpeggio - notes.forEach((f, i) => tone(f, f, 0.16, 'triangle', 0.3, i * 0.09)); + notes.forEach((f, i) => { + const delay = i * 0.09; + [0, 7].forEach((detune) => + tone(f, f, 0.2, 0.22, delay, { type: 'triangle', detune, filterFrom: 800 + i * 400, filterTo: 6000 }), + ); + }); + noiseBurst(ac, 'bandpass', 200, 3000, 3, 0.4, 0.12); + tone(110, 55, 0.35, 0.3, notes.length * 0.09, { type: 'sine' }); // sub hit on landing } -/** Low buzz — an action was rejected. */ +/** Access denied: two harsh low blips through heavy grit, with a noise crackle. */ export function playError(): void { - tone(160, 80, 0.22, 'sawtooth', 0.22); + if (isMuted()) return; + const ac = ensureContext(); + [0, 0.13].forEach((delay) => tone(180, 90, 0.13, 0.25, delay, { type: 'sawtooth', grit: 20 })); + noiseBurst(ac, 'lowpass', 800, 200, 2, 0.18, 0.12); } -/** Short neutral blip for minor UI actions (button clicks, panel open). */ +/** Short digital tick for minor UI actions (button clicks, marker select, sync). */ export function playClick(): void { - tone(600, 500, 0.06, 'sine', 0.12); + if (isMuted()) return; + const ac = ensureContext(); + tone(1800, 1000, 0.05, 0.16, 0, { type: 'square' }); + noiseBurst(ac, 'highpass', 5000, 5000, 6, 0.02, 0.12); } /**