Add SFX and ambient music, with a mute toggle

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.
This commit is contained in:
2026-08-06 11:56:16 +00:00
parent 1584676978
commit acb6510a11
2 changed files with 189 additions and 1 deletions
+159
View File
@@ -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<typeof setTimeout> | 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;
}
}
+30 -1
View File
@@ -1,15 +1,22 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import L from 'leaflet';
import 'leaflet.markercluster';
import { useAuthStore } from '../stores/auth';
import { useGameStore } from '../stores/game';
import type { Place } from '../stores/game';
import type { Team } from '../stores/auth';
import * as audio from '../lib/audio';
const auth = useAuthStore();
const game = useGameStore();
const soundMuted = ref(audio.isMuted());
function toggleSound() {
soundMuted.value = !soundMuted.value;
audio.setMuted(soundMuted.value);
}
const mapEl = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
// Clustered — with places synced worldwide (tens of thousands, not the
@@ -120,6 +127,7 @@ function redraw() {
function onMarkerClick(place: Place) {
selectedPlaceId.value = place.id;
actionError.value = null;
audio.playClick();
}
async function doClaim(place: Place) {
@@ -127,8 +135,10 @@ async function doClaim(place: Place) {
actionBusy.value = true;
try {
await game.claim(place.id);
audio.playClaim();
} catch (err) {
actionError.value = err instanceof Error ? err.message : String(err);
audio.playError();
} finally {
actionBusy.value = false;
}
@@ -137,6 +147,7 @@ async function doClaim(place: Place) {
function startLink(place: Place) {
linkSourceId.value = place.id;
actionError.value = null;
audio.playClick();
}
function cancelLink() {
@@ -147,11 +158,17 @@ async function completeLink(place: Place) {
if (!linkSourceId.value) return;
actionError.value = null;
actionBusy.value = true;
const fieldsBefore = game.fields.length;
try {
await game.link(linkSourceId.value, place.id);
linkSourceId.value = null;
// A bigger, more triumphant sound specifically when this link closed a
// new triangle, not just for any successful link.
if (game.fields.length > fieldsBefore) audio.playField();
else audio.playLink();
} catch (err) {
actionError.value = err instanceof Error ? err.message : String(err);
audio.playError();
} finally {
actionBusy.value = false;
}
@@ -160,10 +177,12 @@ async function completeLink(place: Place) {
async function doSync() {
actionError.value = null;
actionBusy.value = true;
audio.playClick();
try {
await game.syncPlaces();
} catch (err) {
actionError.value = err instanceof Error ? err.message : String(err);
audio.playError();
} finally {
actionBusy.value = false;
}
@@ -218,8 +237,11 @@ onMounted(async () => {
}
await game.refreshAll();
redraw();
if (!soundMuted.value) audio.startMusic();
});
onUnmounted(() => audio.stopMusic());
watch(() => [game.places, game.links, game.fields], redraw, { deep: true });
watch(linkSourceId, redraw);
</script>
@@ -257,6 +279,13 @@ watch(linkSourceId, redraw);
>
Sync BTC Map
</button>
<button
class="rounded border border-cyan-neon/50 bg-black/40 px-3 py-1 text-cyan-neon transition hover:border-cyan-neon hover:shadow-glow-cyan"
:title="soundMuted ? 'Unmute sound' : 'Mute sound'"
@click="toggleSound"
>
{{ soundMuted ? '🔇' : '🔊' }}
</button>
<button
class="rounded border border-magenta-neon/50 bg-black/40 px-3 py-1 text-magenta-neon transition hover:border-magenta-neon hover:shadow-glow-magenta"
@click="auth.logout()"