From 559ee5b61968d2b8c562933699ec65440523168d Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 14:07:09 -0400 Subject: [PATCH] feat: add ambient audio and notification sounds via Web Audio API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New js/audio.js: synthesized ambient drone (A-minor, 4 oscillators) + lookahead arpeggio scheduler; notification chime on job completion - Ambient music starts on first user interaction (browser autoplay policy) - Muted by default on touch/mobile; volume + mute persisted in localStorage - Audio controls widget (♪ mute button + volume slider) added to HUD overlay - websocket.js: playNotification() fires on every job_completed event - ui.js: initAudioControls() wires mute toggle and volume slider - main.js: initAudio() called during firstInit Fixes #5 Co-Authored-By: Claude Sonnet 4.6 --- index.html | 40 ++++++++++ js/audio.js | 199 ++++++++++++++++++++++++++++++++++++++++++++++++ js/main.js | 2 + js/ui.js | 40 ++++++++++ js/websocket.js | 2 + 5 files changed, 283 insertions(+) create mode 100644 js/audio.js diff --git a/index.html b/index.html index 6e51dd4..b2a9419 100644 --- a/index.html +++ b/index.html @@ -147,12 +147,48 @@ } #connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; } + /* ── Audio controls (Issue #5) ── */ + #audio-controls { + position: fixed; bottom: 76px; right: 16px; + display: flex; align-items: center; gap: 6px; + pointer-events: auto; z-index: 20; + } + #audio-mute-btn { + background: transparent; border: 1px solid #003300; + color: #007722; font-size: 13px; line-height: 1; + padding: 2px 5px; cursor: pointer; border-radius: 2px; + font-family: 'Courier New', monospace; + transition: color 0.15s, border-color 0.15s; + } + #audio-mute-btn:hover { color: #00ff41; border-color: #00ff41; } + #audio-mute-btn.active { color: #00ff41; border-color: #007722; text-shadow: 0 0 6px #00ff41; } + #audio-mute-btn.muted { color: #333; border-color: #1a1a1a; } + #audio-volume { + -webkit-appearance: none; appearance: none; + width: 64px; height: 3px; + background: #003300; outline: none; border-radius: 2px; + cursor: pointer; opacity: 0.7; transition: opacity 0.15s; + } + #audio-volume:hover { opacity: 1; } + #audio-volume::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 10px; height: 10px; border-radius: 50%; + background: #00ff41; cursor: pointer; + box-shadow: 0 0 4px #00ff41; + } + #audio-volume::-moz-range-thumb { + width: 10px; height: 10px; border-radius: 50%; + background: #00ff41; cursor: pointer; border: none; + box-shadow: 0 0 4px #00ff41; + } + /* Safe area padding for notched devices */ @supports (padding: env(safe-area-inset-top)) { #hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); } #status-panel { top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right)); } #chat-panel { bottom: calc(52px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); } #connection-status { bottom: calc(52px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); } + #audio-controls { bottom: calc(76px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); } } /* Stack status panel below HUD on narrow viewports (must come AFTER @supports) */ @@ -182,6 +218,10 @@
OFFLINE
+
+ + +
diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..7a4bb86 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,199 @@ +/** + * audio.js — Ambient sound and notification audio for The Matrix. + * Uses Web Audio API exclusively (no audio files needed). + * + * Features: + * - Ambient synthwave drone + arpeggio (starts on first user interaction) + * - Notification chime when agents complete jobs + * - Volume control with localStorage persistence + * - Muted by default on mobile (pointer: coarse) + * + * Resolves Issue #5 — Add ambient sound and notification audio + */ + +const STORAGE_KEY_VOLUME = 'matrix:audio:volume'; +const STORAGE_KEY_MUTED = 'matrix:audio:muted'; + +/* ── Persisted state ── */ + +let _volume = 0.35; +let _muted = false; + +try { + const savedVol = localStorage.getItem(STORAGE_KEY_VOLUME); + if (savedVol !== null) _volume = Math.max(0, Math.min(1, parseFloat(savedVol) || 0.35)); + const savedMute = localStorage.getItem(STORAGE_KEY_MUTED); + if (savedMute !== null) _muted = savedMute === 'true'; +} catch { /* private mode or quota */ } + +// Mute by default on touch/mobile unless user has explicitly set a preference +if (localStorage.getItem(STORAGE_KEY_MUTED) === null) { + _muted = window.matchMedia('(pointer: coarse)').matches; +} + +/* ── AudioContext (created lazily on first user interaction) ── */ + +let ctx = null; +let masterGain = null; +let ambientStarted = false; + +function ensureContext() { + if (ctx) return ctx; + ctx = new (window.AudioContext || window.webkitAudioContext)(); + masterGain = ctx.createGain(); + masterGain.gain.value = _muted ? 0 : _volume; + masterGain.connect(ctx.destination); + return ctx; +} + +/* ── Ambient drone ── */ + +/** + * Start the ambient drone — three sine oscillators forming an A-minor + * power chord, slightly detuned to create a gentle chorus effect. + */ +function startDrone(ac) { + const drones = [ + { freq: 55, detune: 0 }, // A1 + { freq: 82.5, detune: 3 }, // E2 + { freq: 110, detune: -3 }, // A2 + { freq: 165, detune: 2 }, // E3 + ]; + + for (const { freq, detune } of drones) { + const osc = ac.createOscillator(); + const gain = ac.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + osc.detune.value = detune; + gain.gain.value = 0.07; + osc.connect(gain); + gain.connect(masterGain); + osc.start(); + } +} + +/* ── Arpeggio scheduler ── */ + +/** + * Lookahead arpeggio scheduler using precise Web Audio timing. + * Schedules a ~0.5 s window of notes every 200 ms, so the CPU + * cost is minimal and notes are glitch-free regardless of JS GC pauses. + */ +function startArpeggio(ac) { + // A minor pentatonic: A3 C4 E4 G4 A4 + const NOTES = [220, 261.63, 329.63, 392, 440]; + const PATTERN = [0, 2, 1, 3, 2, 4, 3, 1]; + const STEP_S = 60 / 80 / 2; // 8th notes @ 80 BPM ≈ 0.375 s + const LOOKAHEAD = 0.5; // schedule 0.5 s ahead + + let nextNoteTime = ac.currentTime + 1.0; // first note after 1 s + let patternIdx = 0; + + function schedule() { + while (nextNoteTime < ac.currentTime + LOOKAHEAD) { + const freq = NOTES[PATTERN[patternIdx % PATTERN.length]]; + patternIdx++; + + const osc = ac.createOscillator(); + const env = ac.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + + const t = nextNoteTime; + env.gain.setValueAtTime(0, t); + env.gain.linearRampToValueAtTime(0.045, t + 0.015); + env.gain.exponentialRampToValueAtTime(0.0001, t + STEP_S * 0.85); + + osc.connect(env); + env.connect(masterGain); + osc.start(t); + osc.stop(t + STEP_S); + + nextNoteTime += STEP_S; + } + + setTimeout(schedule, 200); + } + + schedule(); +} + +/* ── Public: playNotification ── */ + +/** + * Play a short two-tone chime when an agent completes a job. + * Silently does nothing if muted or AudioContext not yet started. + */ +export function playNotification() { + if (!ctx || ctx.state !== 'running') return; + + const ac = ctx; + const now = ac.currentTime; + + // Ascending minor third: A5 → C#6 + const TONES = [880, 1108.73]; + + TONES.forEach((freq, i) => { + const osc = ac.createOscillator(); + const env = ac.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + + const t = now + i * 0.13; + env.gain.setValueAtTime(0, t); + env.gain.linearRampToValueAtTime(0.18, t + 0.012); + env.gain.exponentialRampToValueAtTime(0.0001, t + 0.55); + + osc.connect(env); + env.connect(masterGain); + osc.start(t); + osc.stop(t + 0.55); + }); +} + +/* ── Public: initAudio ── */ + +/** + * Wire up event listeners so that the ambient synth starts on the + * first user interaction (browser autoplay policy compliance). + */ +export function initAudio() { + function startAmbient() { + if (ambientStarted) return; + ambientStarted = true; + + const ac = ensureContext(); + const go = () => { + startDrone(ac); + startArpeggio(ac); + }; + + if (ac.state === 'suspended') { + ac.resume().then(go).catch(() => {}); + } else { + go(); + } + } + + ['click', 'keydown', 'touchstart', 'pointerdown'].forEach(evt => + window.addEventListener(evt, startAmbient, { once: true, passive: true }), + ); +} + +/* ── Public: volume / mute API ── */ + +export function setVolume(vol) { + _volume = Math.max(0, Math.min(1, vol)); + if (masterGain && !_muted) masterGain.gain.value = _volume; + try { localStorage.setItem(STORAGE_KEY_VOLUME, String(_volume)); } catch {} +} + +export function setMuted(muted) { + _muted = Boolean(muted); + if (masterGain) masterGain.gain.value = _muted ? 0 : _volume; + try { localStorage.setItem(STORAGE_KEY_MUTED, String(_muted)); } catch {} +} + +export function getVolume() { return _volume; } +export function isMuted() { return _muted; } diff --git a/js/main.js b/js/main.js index a9185e2..30d8af7 100644 --- a/js/main.js +++ b/js/main.js @@ -8,6 +8,7 @@ import { initUI, updateUI } from './ui.js'; import { initInteraction, updateControls, disposeInteraction } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initVisitor } from './visitor.js'; +import { initAudio } from './audio.js'; let running = false; let canvas = null; @@ -38,6 +39,7 @@ function buildWorld(firstInit, stateSnapshot) { initUI(); initWebSocket(scene); initVisitor(); + initAudio(); // Dismiss loading screen const loadingScreen = document.getElementById('loading-screen'); diff --git a/js/ui.js b/js/ui.js index 93d95d9..35b1e17 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1,5 +1,6 @@ import { getAgentDefs } from './agents.js'; import { AGENT_DEFS, colorToCss } from './agent-defs.js'; +import { setVolume, setMuted, getVolume, isMuted } from './audio.js'; const $agentCount = document.getElementById('agent-count'); const $activeJobs = document.getElementById('active-jobs'); @@ -92,6 +93,45 @@ export function initUI() { renderAgentList(); loadAllHistories(); if ($clearBtn) $clearBtn.addEventListener('click', clearAllHistories); + initAudioControls(); +} + +function initAudioControls() { + const $muteBtn = document.getElementById('audio-mute-btn'); + const $volSlider = document.getElementById('audio-volume'); + if (!$muteBtn || !$volSlider) return; + + // Set initial slider value from persisted audio state + $volSlider.value = String(Math.round(getVolume() * 100)); + syncMuteBtn($muteBtn); + + $muteBtn.addEventListener('click', () => { + setMuted(!isMuted()); + syncMuteBtn($muteBtn); + }); + + $volSlider.addEventListener('input', () => { + const vol = parseInt($volSlider.value, 10) / 100; + setVolume(vol); + if (isMuted() && vol > 0) { + setMuted(false); + syncMuteBtn($muteBtn); + } + }); +} + +function syncMuteBtn($btn) { + if (isMuted()) { + $btn.textContent = '♪̶'; + $btn.classList.add('muted'); + $btn.classList.remove('active'); + $btn.title = 'Unmute ambient audio'; + } else { + $btn.textContent = '♪'; + $btn.classList.add('active'); + $btn.classList.remove('muted'); + $btn.title = 'Mute ambient audio'; + } } function renderAgentList() { diff --git a/js/websocket.js b/js/websocket.js index bd98be2..54eb358 100644 --- a/js/websocket.js +++ b/js/websocket.js @@ -14,6 +14,7 @@ import { setAgentState, addAgent } from './agents.js'; import { appendChatMessage } from './ui.js'; import { Config } from './config.js'; import { showBark, startDemoBarks, stopDemoBarks } from './bark.js'; +import { playNotification } from './audio.js'; const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d])); @@ -195,6 +196,7 @@ function handleMessage(msg) { if (jobCount > 0) jobCount--; if (msg.agentId) setAgentState(msg.agentId, 'idle'); logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`); + playNotification(); break; } -- 2.43.0