1 Commits

Author SHA1 Message Date
Alexander Whitestone
559ee5b619 feat: add ambient audio and notification sounds via Web Audio API
- 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 <noreply@anthropic.com>
2026-03-23 14:07:09 -04:00
5 changed files with 283 additions and 0 deletions

View File

@@ -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 @@
<button id="chat-clear-btn" title="Clear chat history" style="position:fixed;bottom:60px;right:16px;background:transparent;border:1px solid #003300;color:#00aa00;font-family:monospace;font-size:.7rem;padding:2px 6px;cursor:pointer;z-index:20;opacity:.6">✕ CLEAR</button>
<div id="bark-container"></div>
<div id="connection-status">OFFLINE</div>
<div id="audio-controls">
<button id="audio-mute-btn" title="Toggle mute"></button>
<input id="audio-volume" type="range" min="0" max="100" step="1" title="Volume" />
</div>
</div>
<div id="chat-input-bar">
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />

199
js/audio.js Normal file
View File

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

View File

@@ -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');

View File

@@ -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() {

View File

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