// ═══════════════════════════════════════════════════════════════════ // SPATIAL AUDIO MANAGER — Nexus Spatial Sound for Mnemosyne // ═══════════════════════════════════════════════════════════════════ // // Attaches a Three.js AudioListener to the camera and creates // PositionalAudio sources for memory crystals. Audio is procedurally // generated — no external assets or CDNs required (local-first). // // Each region gets a distinct tone. Proximity controls volume and // panning. Designed to layer on top of SpatialMemory without // modifying it. // // Usage from app.js: // SpatialAudio.init(camera, scene); // SpatialAudio.bindSpatialMemory(SpatialMemory); // SpatialAudio.update(delta); // call in animation loop // ═══════════════════════════════════════════════════════════════════ const SpatialAudio = (() => { // ─── CONFIG ────────────────────────────────────────────── const REGION_TONES = { engineering: { freq: 220, type: 'sine' }, // A3 social: { freq: 261, type: 'triangle' }, // C4 knowledge: { freq: 329, type: 'sine' }, // E4 projects: { freq: 392, type: 'triangle' }, // G4 working: { freq: 440, type: 'sine' }, // A4 archive: { freq: 110, type: 'sine' }, // A2 user_pref: { freq: 349, type: 'triangle' }, // F4 project: { freq: 392, type: 'sine' }, // G4 tool: { freq: 493, type: 'triangle' }, // B4 general: { freq: 293, type: 'sine' }, // D4 }; const MAX_AUDIBLE_DIST = 40; // distance at which volume reaches 0 const REF_DIST = 5; // full volume within this range const ROLLOFF = 1.5; const BASE_VOLUME = 0.12; // master volume cap per source const AMBIENT_VOLUME = 0.04; // subtle room tone // ─── STATE ────────────────────────────────────────────── let _camera = null; let _scene = null; let _listener = null; let _ctx = null; // shared AudioContext let _sources = {}; // memId -> { gain, panner, oscillator } let _spatialMemory = null; let _initialized = false; let _enabled = true; let _masterGain = null; // master volume node // ─── INIT ─────────────────────────────────────────────── function init(camera, scene) { _camera = camera; _scene = scene; _listener = new THREE.AudioListener(); camera.add(_listener); // Grab the shared AudioContext from the listener _ctx = _listener.context; _masterGain = _ctx.createGain(); _masterGain.gain.value = 1.0; _masterGain.connect(_ctx.destination); _initialized = true; console.info('[SpatialAudio] Initialized — AudioContext state:', _ctx.state); // Browsers require a user gesture to resume audio context if (_ctx.state === 'suspended') { const resume = () => { _ctx.resume().then(() => { console.info('[SpatialAudio] AudioContext resumed'); document.removeEventListener('click', resume); document.removeEventListener('keydown', resume); }); }; document.addEventListener('click', resume); document.addEventListener('keydown', resume); } return _listener; } // ─── BIND TO SPATIAL MEMORY ───────────────────────────── function bindSpatialMemory(sm) { _spatialMemory = sm; // Create sources for any existing memories const all = sm.getAllMemories(); all.forEach(mem => _ensureSource(mem)); console.info('[SpatialAudio] Bound to SpatialMemory —', Object.keys(_sources).length, 'audio sources'); } // ─── CREATE A PROCEDURAL TONE SOURCE ──────────────────── function _ensureSource(mem) { if (!_ctx || !_enabled || _sources[mem.id]) return; const regionKey = mem.category || 'working'; const tone = REGION_TONES[regionKey] || REGION_TONES.working; // Procedural oscillator const osc = _ctx.createOscillator(); osc.type = tone.type; osc.frequency.value = tone.freq + _hashOffset(mem.id); // slight per-crystal detune const gain = _ctx.createGain(); gain.gain.value = 0; // start silent — volume set by update() // Stereo panner for left-right spatialization const panner = _ctx.createStereoPanner(); panner.pan.value = 0; osc.connect(gain); gain.connect(panner); panner.connect(_masterGain); osc.start(); _sources[mem.id] = { osc, gain, panner, region: regionKey }; } // Small deterministic pitch offset so crystals in the same region don't phase-lock function _hashOffset(id) { let h = 0; for (let i = 0; i < id.length; i++) { h = ((h << 5) - h) + id.charCodeAt(i); h |= 0; } return (Math.abs(h) % 40) - 20; // ±20 Hz } // ─── PER-FRAME UPDATE ─────────────────────────────────── function update() { if (!_initialized || !_enabled || !_spatialMemory || !_camera) return; const camPos = _camera.position; const memories = _spatialMemory.getAllMemories(); // Ensure sources for newly placed memories memories.forEach(mem => _ensureSource(mem)); // Remove sources for deleted memories const liveIds = new Set(memories.map(m => m.id)); Object.keys(_sources).forEach(id => { if (!liveIds.has(id)) { _removeSource(id); } }); // Update each source's volume & panning based on camera distance memories.forEach(mem => { const src = _sources[mem.id]; if (!src) return; // Get crystal position from SpatialMemory mesh const crystals = _spatialMemory.getCrystalMeshes(); let meshPos = null; for (const mesh of crystals) { if (mesh.userData.memId === mem.id) { meshPos = mesh.position; break; } } if (!meshPos) return; const dx = meshPos.x - camPos.x; const dy = meshPos.y - camPos.y; const dz = meshPos.z - camPos.z; const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); // Volume rolloff (inverse distance model) let vol = 0; if (dist < MAX_AUDIBLE_DIST) { vol = BASE_VOLUME / (1 + ROLLOFF * (dist - REF_DIST)); vol = Math.max(0, Math.min(BASE_VOLUME, vol)); } src.gain.gain.setTargetAtTime(vol, _ctx.currentTime, 0.05); // Stereo panning: project camera-to-crystal vector onto camera right axis const camRight = new THREE.Vector3(); _camera.getWorldDirection(camRight); camRight.cross(_camera.up).normalize(); const toCrystal = new THREE.Vector3(dx, 0, dz).normalize(); const pan = THREE.MathUtils.clamp(toCrystal.dot(camRight), -1, 1); src.panner.pan.setTargetAtTime(pan, _ctx.currentTime, 0.05); }); } function _removeSource(id) { const src = _sources[id]; if (!src) return; try { src.osc.stop(); src.osc.disconnect(); src.gain.disconnect(); src.panner.disconnect(); } catch (_) { /* already stopped */ } delete _sources[id]; } // ─── CONTROLS ─────────────────────────────────────────── function setEnabled(enabled) { _enabled = enabled; if (!_enabled) { // Silence all sources Object.values(_sources).forEach(src => { src.gain.gain.setTargetAtTime(0, _ctx.currentTime, 0.05); }); } console.info('[SpatialAudio]', enabled ? 'Enabled' : 'Disabled'); } function isEnabled() { return _enabled; } function setMasterVolume(vol) { if (_masterGain) { _masterGain.gain.setTargetAtTime( THREE.MathUtils.clamp(vol, 0, 1), _ctx.currentTime, 0.05 ); } } function getActiveSourceCount() { return Object.keys(_sources).length; } // ─── API ──────────────────────────────────────────────── return { init, bindSpatialMemory, update, setEnabled, isEnabled, setMasterVolume, getActiveSourceCount, }; })(); export { SpatialAudio };