diff --git a/site/beacon/index.html b/site/beacon/index.html
index 25415c738..9cbf4662d 100644
--- a/site/beacon/index.html
+++ b/site/beacon/index.html
@@ -67,13 +67,14 @@
[NEW CONTRACT]
[BOUNTIES]
+
DRAG rotate | SCROLL zoom | RIGHT-DRAG pan
- CLICK agent/city for details | ESC close
+ CLICK agent/city for details | SOUND toggle | ESC close
@@ -112,6 +113,7 @@
import { buildVehicles } from './vehicles.js';
import { buildBounties } from './bounties.js';
import { initUI, openContractForm, openBountiesPanel } from './ui.js';
+ import { initSoundControls } from './sound.js';
import { fetchAllAgents, replaceContracts, AGENTS, CITIES, CONTRACTS } from './data.js';
const fill = document.getElementById('loading-fill');
@@ -207,6 +209,7 @@
await tick();
setupInteraction(canvas);
initUI();
+ initSoundControls(document.getElementById('hud-sound'));
// Force HUD update with current counts
const hudEl = document.querySelector('.hud-stats');
diff --git a/site/beacon/sound.js b/site/beacon/sound.js
new file mode 100644
index 000000000..ed5de990b
--- /dev/null
+++ b/site/beacon/sound.js
@@ -0,0 +1,218 @@
+// SPDX-License-Identifier: MIT
+
+const MASTER_LEVEL = 0.045;
+const MAX_TRANSIENTS = 6;
+const HOVER_COOLDOWN_MS = 120;
+
+let audioContext = null;
+let masterGain = null;
+let ambientNodes = [];
+let soundControl = null;
+let soundEnabled = false;
+let soundSupported = true;
+let activeTransients = 0;
+let lastHoverAt = 0;
+let suspendTimer = null;
+let delegatedEventsBound = false;
+
+function audioContextConstructor() {
+ return globalThis.AudioContext || globalThis.webkitAudioContext || null;
+}
+
+function setParam(param, value, time) {
+ if (typeof param.setValueAtTime === 'function') {
+ param.setValueAtTime(value, time);
+ } else {
+ param.value = value;
+ }
+}
+
+function buildAudioGraph() {
+ if (audioContext && audioContext.state !== 'closed') return audioContext;
+
+ const AudioContextClass = audioContextConstructor();
+ if (!AudioContextClass) {
+ soundSupported = false;
+ updateControl();
+ return null;
+ }
+
+ audioContext = new AudioContextClass();
+ masterGain = audioContext.createGain();
+ setParam(masterGain.gain, 0, audioContext.currentTime);
+ masterGain.connect(audioContext.destination);
+
+ const lowPass = audioContext.createBiquadFilter();
+ lowPass.type = 'lowpass';
+ setParam(lowPass.frequency, 180, audioContext.currentTime);
+ setParam(lowPass.Q, 0.7, audioContext.currentTime);
+ lowPass.connect(masterGain);
+
+ ambientNodes = [
+ { frequency: 55, level: 0.32, type: 'sine' },
+ { frequency: 82.5, level: 0.12, type: 'triangle' },
+ ].map(({ frequency, level, type }) => {
+ const oscillator = audioContext.createOscillator();
+ const gain = audioContext.createGain();
+ oscillator.type = type;
+ setParam(oscillator.frequency, frequency, audioContext.currentTime);
+ setParam(gain.gain, level, audioContext.currentTime);
+ oscillator.connect(gain);
+ gain.connect(lowPass);
+ oscillator.start();
+ return { oscillator, gain };
+ });
+
+ return audioContext;
+}
+
+function updateControl() {
+ if (!soundControl) return;
+
+ if (!soundSupported) {
+ soundControl.textContent = '[SOUND N/A]';
+ soundControl.disabled = true;
+ soundControl.setAttribute('aria-label', 'Sound is not supported by this browser');
+ soundControl.setAttribute('aria-pressed', 'false');
+ return;
+ }
+
+ soundControl.disabled = false;
+ soundControl.textContent = soundEnabled ? '[SOUND ON]' : '[SOUND OFF]';
+ soundControl.setAttribute('aria-label', soundEnabled ? 'Mute Beacon Atlas sound' : 'Enable Beacon Atlas sound');
+ soundControl.setAttribute('aria-pressed', String(soundEnabled));
+ soundControl.classList.toggle('is-active', soundEnabled);
+}
+
+function fadeMaster(target, seconds) {
+ if (!audioContext || !masterGain) return;
+ const now = audioContext.currentTime;
+ const gain = masterGain.gain;
+ gain.cancelScheduledValues?.(now);
+ setParam(gain, gain.value, now);
+ gain.linearRampToValueAtTime?.(target, now + seconds);
+ if (typeof gain.linearRampToValueAtTime !== 'function') gain.value = target;
+}
+
+export async function setSoundEnabled(enabled) {
+ if (!enabled) {
+ soundEnabled = false;
+ fadeMaster(0, 0.18);
+ updateControl();
+
+ clearTimeout(suspendTimer);
+ suspendTimer = setTimeout(() => {
+ if (!soundEnabled && audioContext?.state === 'running') audioContext.suspend();
+ }, 220);
+ return false;
+ }
+
+ const context = buildAudioGraph();
+ if (!context) return false;
+
+ clearTimeout(suspendTimer);
+ try {
+ if (context.state === 'suspended') await context.resume();
+ } catch (error) {
+ console.warn('[sound] Browser blocked audio activation:', error.message);
+ soundEnabled = false;
+ updateControl();
+ return false;
+ }
+
+ soundEnabled = context.state !== 'closed';
+ fadeMaster(MASTER_LEVEL, 0.25);
+ updateControl();
+ return soundEnabled;
+}
+
+function playTone(frequency, duration, level, waveform = 'sine') {
+ if (!soundEnabled || audioContext?.state !== 'running' || !masterGain) return false;
+ if (activeTransients >= MAX_TRANSIENTS) return false;
+
+ const oscillator = audioContext.createOscillator();
+ const gain = audioContext.createGain();
+ const now = audioContext.currentTime;
+ oscillator.type = waveform;
+ setParam(oscillator.frequency, frequency, now);
+ setParam(gain.gain, 0.0001, now);
+ gain.gain.exponentialRampToValueAtTime?.(level, now + 0.01);
+ gain.gain.exponentialRampToValueAtTime?.(0.0001, now + duration);
+ oscillator.connect(gain);
+ gain.connect(masterGain);
+
+ activeTransients += 1;
+ oscillator.addEventListener('ended', () => {
+ oscillator.disconnect();
+ gain.disconnect();
+ activeTransients = Math.max(0, activeTransients - 1);
+ }, { once: true });
+ oscillator.start(now);
+ oscillator.stop(now + duration + 0.02);
+ return true;
+}
+
+export function playHoverTone() {
+ const now = performance.now();
+ if (now - lastHoverAt < HOVER_COOLDOWN_MS) return false;
+ lastHoverAt = now;
+ return playTone(720, 0.045, 0.12, 'sine');
+}
+
+export function playClickTone(kind = 'default') {
+ const frequencies = {
+ agent: 520,
+ city: 390,
+ close: 220,
+ toggle: 660,
+ default: 460,
+ };
+ return playTone(frequencies[kind] || frequencies.default, 0.09, 0.2, 'triangle');
+}
+
+function bindDelegatedFeedback() {
+ if (delegatedEventsBound || !globalThis.document?.addEventListener) return;
+ delegatedEventsBound = true;
+ const selector = 'a, button, [role="button"], .panel-dot, .bounty-card, .contract-new-btn';
+
+ document.addEventListener('pointerover', (event) => {
+ const target = event.target.closest?.(selector);
+ if (!target || target.contains(event.relatedTarget) || target === soundControl) return;
+ playHoverTone();
+ });
+
+ document.addEventListener('click', (event) => {
+ const target = event.target.closest?.(selector);
+ if (target && target !== soundControl) playClickTone();
+ });
+}
+
+export function initSoundControls(control) {
+ soundControl = control;
+ updateControl();
+ bindDelegatedFeedback();
+
+ if (!control) return;
+ control.addEventListener('click', async () => {
+ const activated = await setSoundEnabled(!soundEnabled);
+ if (activated) playClickTone('toggle');
+ });
+ globalThis.window?.addEventListener('pagehide', disposeSound, { once: true });
+}
+
+export function disposeSound() {
+ clearTimeout(suspendTimer);
+ soundEnabled = false;
+ ambientNodes.forEach(({ oscillator, gain }) => {
+ try { oscillator.stop(); } catch {}
+ oscillator.disconnect();
+ gain.disconnect();
+ });
+ ambientNodes = [];
+ masterGain?.disconnect();
+ masterGain = null;
+ if (audioContext && audioContext.state !== 'closed') audioContext.close();
+ audioContext = null;
+ activeTransients = 0;
+ updateControl();
+}
diff --git a/site/beacon/styles.css b/site/beacon/styles.css
index 05b060fc1..7500200e2 100644
--- a/site/beacon/styles.css
+++ b/site/beacon/styles.css
@@ -606,6 +606,14 @@ html, body {
transition: text-shadow 0.2s;
}
+button.hud-action {
+ appearance: none;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ line-height: inherit;
+}
+
.hud-actions {
display: flex;
gap: 16px;
@@ -616,6 +624,23 @@ html, body {
color: #ffc833;
}
+.hud-sound.is-active,
+.hud-sound[aria-pressed="true"] {
+ color: var(--green);
+ text-shadow: 0 0 12px var(--green-glow);
+}
+
+.hud-sound:focus-visible {
+ outline: 1px dashed var(--amber);
+ outline-offset: 3px;
+}
+
+.hud-sound:disabled {
+ color: var(--text-dim);
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+
/* --- Bounty cards --- */
.bounty-card {
background: rgba(0, 20, 0, 0.6);
diff --git a/site/beacon/ui.js b/site/beacon/ui.js
index bcc27d986..3d62a20d3 100644
--- a/site/beacon/ui.js
+++ b/site/beacon/ui.js
@@ -11,6 +11,7 @@ import { getAgentPosition, highlightAgent } from './agents.js';
import { getCityCenter } from './cities.js';
import { highlightAgentConnections, addContractLine } from './connections.js';
import { initChat, setCurrentAgent, getChatHTML, bindChatEvents } from './chat.js';
+import { playClickTone, playHoverTone } from './sound.js';
const BEACON_API = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? 'http://localhost:8071'
@@ -20,15 +21,7 @@ let panel, panelContent, panelPath, tooltip;
let selectedAgent = null;
let selectedCity = null;
let hoveredId = null;
-
-function escapeHtml(value) {
- return String(value ?? '')
- .replaceAll('&', '&')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
- .replaceAll('"', '"')
- .replaceAll("'", ''');
-}
+let hoveredObjectKey = null;
function safeNumber(value, fallback = 0, min = 0, max = Number.MAX_SAFE_INTEGER) {
const number = Number(value);
@@ -107,7 +100,10 @@ export function initUI() {
tooltip = document.querySelector('.tooltip');
// Close button
- document.querySelector('.panel-dot').addEventListener('click', closePanel);
+ document.querySelector('.panel-dot').addEventListener('click', () => {
+ playClickTone('close');
+ closePanel();
+ });
// HUD stats
updateHUD();
@@ -166,6 +162,8 @@ function setPanelPath(path) {
function onObjectClick(mesh) {
const data = mesh.userData;
+ if (data.type === 'agent' || data.type === 'city') playClickTone(data.type);
+
if (data.type === 'agent') {
selectAgent(data.agentId);
} else if (data.type === 'city') {
@@ -179,6 +177,7 @@ function onObjectHover(hit, event) {
highlightAgent(hoveredId, false);
hoveredId = null;
}
+ hoveredObjectKey = null;
tooltip.classList.remove('visible');
document.body.style.cursor = 'default';
return;
@@ -187,6 +186,14 @@ function onObjectHover(hit, event) {
const data = hit.object.userData;
document.body.style.cursor = 'pointer';
+ const hoverKey = data.type === 'agent'
+ ? `agent:${data.agentId}`
+ : data.type === 'city' ? `city:${data.cityId}` : null;
+ if (hoverKey && hoverKey !== hoveredObjectKey) {
+ hoveredObjectKey = hoverKey;
+ playHoverTone();
+ }
+
if (data.type === 'agent' && data.agentId !== hoveredId) {
if (hoveredId) highlightAgent(hoveredId, false);
hoveredId = data.agentId;
diff --git a/tests/test_beacon_atlas.py b/tests/test_beacon_atlas.py
index 034ae1e53..ab5a9a0a0 100644
--- a/tests/test_beacon_atlas.py
+++ b/tests/test_beacon_atlas.py
@@ -8,6 +8,10 @@
import time
import sys
import os
+import pathlib
+import re
+import subprocess
+import textwrap
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -369,6 +373,120 @@ def test_vehicle_type_distribution(self):
self.assertLess(prob, 1.0)
+class TestBeaconAtlasSoundDesign(unittest.TestCase):
+ """Test the user-gesture-safe Beacon Atlas sound layer."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.beacon_dir = pathlib.Path(__file__).resolve().parents[1] / "site" / "beacon"
+ cls.sound_source = (cls.beacon_dir / "sound.js").read_text(encoding="utf-8")
+
+ def test_sound_controls_are_wired_into_the_live_atlas(self):
+ index_source = (self.beacon_dir / "index.html").read_text(encoding="utf-8")
+ ui_source = (self.beacon_dir / "ui.js").read_text(encoding="utf-8")
+
+ self.assertIn('id="hud-sound"', index_source)
+ self.assertIn('aria-pressed="false"', index_source)
+ self.assertIn("initSoundControls(document.getElementById('hud-sound'))", index_source)
+ self.assertIn("playHoverTone()", ui_source)
+ self.assertIn("playClickTone(data.type)", ui_source)
+ self.assertIn("MAX_TRANSIENTS", self.sound_source)
+ self.assertIn("pagehide", self.sound_source)
+
+ def test_audio_context_is_deferred_until_activation_and_cleaned_up(self):
+ executable_source = re.sub(r"^export\s+", "", self.sound_source, flags=re.MULTILINE)
+ probe = textwrap.dedent(r"""
+ let contextsCreated = 0;
+ let contextsClosed = 0;
+ const listeners = {};
+ const pendingEnded = [];
+
+ function audioParam() {
+ return {
+ value: 0,
+ setValueAtTime(value) { this.value = value; },
+ linearRampToValueAtTime(value) { this.value = value; },
+ exponentialRampToValueAtTime(value) { this.value = value; },
+ cancelScheduledValues() {},
+ };
+ }
+
+ class MockNode {
+ constructor() {
+ this.frequency = audioParam();
+ this.Q = audioParam();
+ this.gain = audioParam();
+ this.ended = null;
+ }
+ connect() { return this; }
+ disconnect() {}
+ start() {}
+ stop() { if (this.ended) pendingEnded.push(this.ended); }
+ addEventListener(name, handler) {
+ if (name === 'ended') this.ended = handler;
+ }
+ }
+
+ class MockAudioContext {
+ constructor() {
+ contextsCreated += 1;
+ this.currentTime = 0;
+ this.destination = new MockNode();
+ this.state = 'suspended';
+ }
+ createGain() { return new MockNode(); }
+ createBiquadFilter() { return new MockNode(); }
+ createOscillator() { return new MockNode(); }
+ async resume() { this.state = 'running'; }
+ async suspend() { this.state = 'suspended'; }
+ async close() { this.state = 'closed'; contextsClosed += 1; }
+ }
+
+ globalThis.AudioContext = MockAudioContext;
+ globalThis.window = { addEventListener() {} };
+ globalThis.document = { addEventListener() {} };
+ const control = {
+ textContent: '',
+ disabled: false,
+ attributes: {},
+ classList: { toggle() {} },
+ setAttribute(name, value) { this.attributes[name] = value; },
+ addEventListener(name, handler) { listeners[name] = handler; },
+ };
+
+ if (contextsCreated !== 0) throw new Error('AudioContext was created before a user gesture');
+ initSoundControls(control);
+ if (contextsCreated !== 0) throw new Error('initialization created an AudioContext');
+ if (control.attributes['aria-pressed'] !== 'false') throw new Error('control did not start muted');
+
+ await listeners.click();
+ if (contextsCreated !== 1) throw new Error('activation did not create exactly one AudioContext');
+ if (control.attributes['aria-pressed'] !== 'true') throw new Error('control did not report enabled state');
+ pendingEnded.splice(0).forEach(handler => handler());
+
+ const toneResults = Array.from({ length: 7 }, () => playClickTone('agent'));
+ if (toneResults.filter(Boolean).length !== 6 || toneResults[6] !== false) {
+ throw new Error('transient sound concurrency was not bounded');
+ }
+
+ await listeners.click();
+ if (contextsCreated !== 1) throw new Error('mute created a second AudioContext');
+ if (control.attributes['aria-pressed'] !== 'false') throw new Error('control did not report muted state');
+ disposeSound();
+ await Promise.resolve();
+ if (contextsClosed !== 1) throw new Error('AudioContext was not closed during cleanup');
+ """)
+
+ result = subprocess.run(
+ ["node", "--input-type=module", "-"],
+ input=f"{executable_source}\n{probe}",
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+
def run_tests():
"""Run all test suites."""
loader = unittest.TestLoader()
@@ -379,6 +497,7 @@ def run_tests():
suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasVisualization))
suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasDataIntegrity))
suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasIntegration))
+ suite.addTests(loader.loadTestsFromTestCase(TestBeaconAtlasSoundDesign))
# Run tests
runner = unittest.TextTestRunner(verbosity=2)