From 9ca93cc6f3272e0c55b47e0a465f2d47ee921631 Mon Sep 17 00:00:00 2001 From: SageHaven Date: Thu, 13 Aug 2026 21:54:11 +0100 Subject: [PATCH] Bounty #1524: feat(beacon): add user-controlled sound design --- site/beacon/index.html | 5 +- site/beacon/sound.js | 218 +++++++++++++++++++++++++++++++++++++ site/beacon/styles.css | 25 +++++ site/beacon/ui.js | 27 +++-- tests/test_beacon_atlas.py | 119 ++++++++++++++++++++ 5 files changed, 383 insertions(+), 11 deletions(-) create mode 100644 site/beacon/sound.js 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)