From acb6510a11fb9291767efbed27bebe931df43ab6 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 6 Aug 2026 11:56:16 +0000 Subject: [PATCH] Add SFX and ambient music, with a mute toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything is synthesized via Web Audio (oscillators/filters) — no external audio files, since sourcing licensed music/SFX isn't something to do without the rights to it, and synthesis fits the HUD aesthetic anyway: - Claim: rising power-up blip - Link: descending zap - Field closes: ascending 4-note arpeggio (distinct from a plain link — checks fields.length before/after to tell them apart) - Rejected action: low buzz - Minor UI (marker select, sync): short neutral blip - Background: generative ambient loop (minor-pentatonic arpeggio through a lowpass filter + short feedback delay), starts on map mount, stops on unmount (was leaking into the login screen on logout otherwise) 🔊/🔇 toggle in the header, persisted to localStorage (regress_muted), takes effect immediately including stopping/resuming the ambient loop. --- frontend/src/lib/audio.ts | 159 +++++++++++++++++++++++++++++++++ frontend/src/views/MapView.vue | 31 ++++++- 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/audio.ts diff --git a/frontend/src/lib/audio.ts b/frontend/src/lib/audio.ts new file mode 100644 index 0000000..31cba20 --- /dev/null +++ b/frontend/src/lib/audio.ts @@ -0,0 +1,159 @@ +// 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. +// +// Browsers block audio before a user gesture, so the AudioContext is +// created lazily on first interaction (see ensureContext()) rather than at +// module load. + +let ctx: AudioContext | null = null; +let masterGain: GainNode | null = null; +let musicGain: GainNode | null = null; +let sfxGain: GainNode | null = null; +let musicTimer: ReturnType | null = null; +let musicPlaying = false; + +const MUTE_KEY = 'regress_muted'; + +export function isMuted(): boolean { + return localStorage.getItem(MUTE_KEY) === '1'; +} + +function ensureContext(): AudioContext { + if (!ctx) { + ctx = new AudioContext(); + masterGain = ctx.createGain(); + masterGain.gain.value = isMuted() ? 0 : 1; + masterGain.connect(ctx.destination); + + musicGain = ctx.createGain(); + musicGain.gain.value = 0.18; // ambient bed sits well under SFX + musicGain.connect(masterGain); + + sfxGain = ctx.createGain(); + sfxGain.gain.value = 0.35; + sfxGain.connect(masterGain); + } + if (ctx.state === 'suspended') void ctx.resume(); + return ctx; +} + +export function setMuted(muted: boolean): void { + localStorage.setItem(MUTE_KEY, muted ? '1' : '0'); + if (masterGain) masterGain.gain.setTargetAtTime(muted ? 0 : 1, ensureContext().currentTime, 0.05); + if (muted) stopMusic(); + else startMusic(); +} + +function envelope(gain: GainNode, ac: AudioContext, attack: number, peak: number, release: number, delay = 0) { + const t = ac.currentTime + delay; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(peak, t + attack); + gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + release); +} + +function tone( + freqStart: number, + freqEnd: number, + duration: number, + type: OscillatorType, + peak: number, + delay = 0, +) { + if (isMuted()) return; + const ac = ensureContext(); + const osc = ac.createOscillator(); + 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); + gain.connect(sfxGain!); + osc.start(ac.currentTime + delay); + osc.stop(ac.currentTime + delay + duration + 0.05); +} + +/** Rising power-up blip — a place changes hands to you. */ +export function playClaim(): void { + tone(280, 900, 0.18, 'square', 0.25); +} + +/** Quick descending zap — a link forms. */ +export function playLink(): void { + tone(1200, 300, 0.12, 'sawtooth', 0.2); +} + +/** Ascending triumphant arpeggio — a field closes. */ +export function playField(): void { + 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)); +} + +/** Low buzz — an action was rejected. */ +export function playError(): void { + tone(160, 80, 0.22, 'sawtooth', 0.22); +} + +/** Short neutral blip for minor UI actions (button clicks, panel open). */ +export function playClick(): void { + tone(600, 500, 0.06, 'sine', 0.12); +} + +/** + * Generative ambient loop: a slow minor-pentatonic arpeggio through a + * lowpass filter with a short feedback delay for a bit of space/echo — + * cheap way to get a synthwave-ish bed without a real composed track. + */ +function scheduleMusicStep(ac: AudioContext, scale: number[]) { + if (!musicPlaying) return; + + const root = 110; // A2 + const freq = root * scale[Math.floor(Math.random() * scale.length)]; + const osc = ac.createOscillator(); + const gain = ac.createGain(); + const filter = ac.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 1200; + filter.Q.value = 1; + + osc.type = 'triangle'; + osc.frequency.value = freq; + + const delay = ac.createDelay(); + delay.delayTime.value = 0.375; + const feedback = ac.createGain(); + feedback.gain.value = 0.25; + delay.connect(feedback); + feedback.connect(delay); + + envelope(gain, ac, 0.4, 0.5, 1.8); + osc.connect(filter); + filter.connect(gain); + gain.connect(musicGain!); + gain.connect(delay); + delay.connect(musicGain!); + + osc.start(); + osc.stop(ac.currentTime + 2.5); + + const nextIn = 900 + Math.random() * 700; // ms — loose, not metronomic + musicTimer = setTimeout(() => scheduleMusicStep(ac, scale), nextIn); +} + +export function startMusic(): void { + if (musicPlaying || isMuted()) return; + const ac = ensureContext(); + musicPlaying = true; + // A minor pentatonic ratios over the root, for a moody but consonant loop. + scheduleMusicStep(ac, [1, 1.2, 1.35, 1.5, 1.8]); +} + +export function stopMusic(): void { + musicPlaying = false; + if (musicTimer) { + clearTimeout(musicTimer); + musicTimer = null; + } +} diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index 66afd37..3c6e7ff 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -1,15 +1,22 @@ @@ -257,6 +279,13 @@ watch(linkSourceId, redraw); > ↻ Sync BTC Map +