Redesign SFX to actually sound cyberpunk, not just plain blips

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.
This commit is contained in:
2026-08-06 12:29:24 +00:00
parent acb6510a11
commit 66ac521037
+122 -23
View File
@@ -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<typeof setTimeout> | 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<ArrayBuffer> {
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);
}
/**