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;
}