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 +