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:
+122
-23
@@ -1,7 +1,7 @@
|
|||||||
// Web Audio sound engine — synthesized SFX + a generative ambient music
|
// Web Audio sound engine — synthesized SFX + a generative ambient music
|
||||||
// loop, all generated on the fly (oscillators/filters), no audio files.
|
// loop, all generated on the fly (oscillators/filters/noise/distortion), no
|
||||||
// Real licensed music/SFX isn't something to source without the rights to
|
// audio files. Real licensed music/SFX isn't something to source without
|
||||||
// it, and synthesis fits the HUD aesthetic anyway.
|
// the rights to it, and synthesis fits the HUD aesthetic anyway.
|
||||||
//
|
//
|
||||||
// Browsers block audio before a user gesture, so the AudioContext is
|
// Browsers block audio before a user gesture, so the AudioContext is
|
||||||
// created lazily on first interaction (see ensureContext()) rather than at
|
// 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 sfxGain: GainNode | null = null;
|
||||||
let musicTimer: ReturnType<typeof setTimeout> | null = null;
|
let musicTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let musicPlaying = false;
|
let musicPlaying = false;
|
||||||
|
let noiseBuffer: AudioBuffer | null = null;
|
||||||
|
|
||||||
const MUTE_KEY = 'regress_muted';
|
const MUTE_KEY = 'regress_muted';
|
||||||
|
|
||||||
@@ -32,8 +33,15 @@ function ensureContext(): AudioContext {
|
|||||||
musicGain.connect(masterGain);
|
musicGain.connect(masterGain);
|
||||||
|
|
||||||
sfxGain = ctx.createGain();
|
sfxGain = ctx.createGain();
|
||||||
sfxGain.gain.value = 0.35;
|
sfxGain.gain.value = 0.4;
|
||||||
sfxGain.connect(masterGain);
|
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();
|
if (ctx.state === 'suspended') void ctx.resume();
|
||||||
return ctx;
|
return ctx;
|
||||||
@@ -53,52 +61,143 @@ function envelope(gain: GainNode, ac: AudioContext, attack: number, peak: number
|
|||||||
gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + release);
|
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,
|
freqStart: number,
|
||||||
freqEnd: number,
|
freqEnd: number,
|
||||||
|
q: number,
|
||||||
duration: number,
|
duration: number,
|
||||||
type: OscillatorType,
|
|
||||||
peak: number,
|
peak: number,
|
||||||
delay = 0,
|
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;
|
if (isMuted()) return;
|
||||||
const ac = ensureContext();
|
const ac = ensureContext();
|
||||||
|
const t = ac.currentTime + delay;
|
||||||
const osc = ac.createOscillator();
|
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();
|
const gain = ac.createGain();
|
||||||
osc.type = type;
|
envelope(gain, ac, duration * 0.12, peak, duration * 0.88, delay);
|
||||||
osc.frequency.setValueAtTime(freqStart, ac.currentTime + delay);
|
|
||||||
osc.frequency.exponentialRampToValueAtTime(Math.max(freqEnd, 1), ac.currentTime + delay + duration);
|
let node: AudioNode = osc;
|
||||||
envelope(gain, ac, duration * 0.15, peak, duration * 0.85, delay);
|
if (opts.filterFrom != null) {
|
||||||
osc.connect(gain);
|
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!);
|
gain.connect(sfxGain!);
|
||||||
osc.start(ac.currentTime + delay);
|
osc.start(t);
|
||||||
osc.stop(ac.currentTime + delay + duration + 0.05);
|
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 {
|
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 {
|
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 {
|
export function playField(): void {
|
||||||
|
if (isMuted()) return;
|
||||||
|
const ac = ensureContext();
|
||||||
const notes = [440, 554.37, 659.25, 880]; // A major-ish arpeggio
|
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 {
|
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 {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user