diff --git a/app.js b/app.js index 46b6e0c1..1330b02a 100644 --- a/app.js +++ b/app.js @@ -10,6 +10,7 @@ import { MemoryOptimizer } from './nexus/components/memory-optimizer.js'; import { MemoryInspect } from './nexus/components/memory-inspect.js'; import { MemoryPulse } from './nexus/components/memory-pulse.js'; import { ReasoningTrace } from './nexus/components/reasoning-trace.js'; +import { SpatialChat } from './nexus/components/spatial-chat.js'; // ═══════════════════════════════════════════ // NEXUS v1.1 — Portal System Update @@ -761,6 +762,7 @@ async function init() { SpatialMemory.setCamera(camera); SpatialAudio.init(camera, scene); SpatialAudio.bindSpatialMemory(SpatialMemory); + SpatialChat.init(camera, scene); MemoryInspect.init({ onNavigate: _navigateToMemory }); MemoryPulse.init(SpatialMemory); ReasoningTrace.init(); @@ -2957,6 +2959,13 @@ function loadSession() { } function addChatMessage(agent, text, shouldSave = true) { + // Play spatial notification sound + if (agent !== 'system' && SpatialChat.isEnabled()) { + // For now, play a generic notification + // In the future, we could map agent names to avatar IDs + SpatialChat.playChatNotification(); + } + // Mine chat messages for MemPalace mineMemPalaceContent(text); // Mine chat messages for MemPalace @@ -3354,6 +3363,7 @@ function gameLoop() { if (typeof animateMemoryOrbs === 'function') { SpatialMemory.update(delta); SpatialAudio.update(delta); + SpatialChat.update(delta); MemoryBirth.update(delta); MemoryPulse.update(); animateMemoryOrbs(delta); diff --git a/nexus/components/spatial-chat.js b/nexus/components/spatial-chat.js new file mode 100644 index 00000000..c7aa23b4 --- /dev/null +++ b/nexus/components/spatial-chat.js @@ -0,0 +1,269 @@ +// ═══════════════════════════════════════════════════════════════════ +// SPATIAL CHAT — 3D Audio Chat Notifications +// ═══════════════════════════════════════════════════════════════════ +// +// Plays chat notification sounds with volume based on avatar distance. +// Provides spatial awareness so near users are louder. +// +// Usage from app.js: +// SpatialChat.init(camera, scene); +// SpatialChat.bindAvatars(avatarManager); +// SpatialChat.playNotification('chat', avatarId); +// SpatialChat.update(delta); // call in animation loop +// ═══════════════════════════════════════════════════════════════════ + +const SpatialChat = (() => { + + // ─── CONFIG ────────────────────────────────────────────── + const CONFIG = { + maxHearingDistance: 50, // distance at which volume reaches 0 + referenceDistance: 5, // full volume within this range + rolloff: 1.5, // how quickly volume drops off + baseVolume: 0.3, // master volume cap per source + notificationDuration: 0.5, // seconds for notification sound + chatNotificationFreq: 800, // Hz for chat notification + voiceNotificationFreq: 600, // Hz for voice notification + enablePositionalAudio: true, // enable 3D positional audio + enableNotifications: true, // enable notification sounds + }; + + // ─── STATE ────────────────────────────────────────────── + let _camera = null; + let _scene = null; + let _listener = null; + let _ctx = null; // shared AudioContext + let _sources = {}; // avatarId -> { gain, panner, oscillator } + let _avatars = {}; // avatarId -> { position: THREE.Vector3 } + let _initialized = false; + let _enabled = true; + let _masterGain = null; // master volume node + + // ─── INITIALIZATION ────────────────────────────────────── + + function init(camera, scene) { + if (_initialized) return; + + _camera = camera; + _scene = scene; + + // Create audio listener attached to camera + _listener = new THREE.AudioListener(); + camera.add(_listener); + + // Get shared AudioContext + _ctx = _listener.context; + _masterGain = _ctx.createGain(); + _masterGain.gain.value = 1.0; + _masterGain.connect(_ctx.destination); + + _initialized = true; + console.log('[SpatialChat] Initialized'); + } + + function bindAvatars(avatarManager) { + if (!avatarManager) return; + + // Store reference to avatars + _avatars = avatarManager.getAvatars ? avatarManager.getAvatars() : {}; + + console.log('[SpatialChat] Bound to avatar manager'); + } + + // ─── NOTIFICATION SOUNDS ────────────────────────────────── + + function playNotification(type = 'chat', avatarId = null) { + if (!_initialized || !_enabled || !CONFIG.enableNotifications) return; + + // Resume audio context if suspended (browser autoplay policy) + if (_ctx.state === 'suspended') { + _ctx.resume(); + } + + const freq = type === 'voice' ? CONFIG.voiceNotificationFreq : CONFIG.chatNotificationFreq; + const duration = CONFIG.notificationDuration; + + // Create oscillator for notification sound + const oscillator = _ctx.createOscillator(); + const gainNode = _ctx.createGain(); + const panner = _ctx.createPanner(); + + // Configure oscillator + oscillator.type = 'sine'; + oscillator.frequency.value = freq; + + // Configure gain envelope (attack-decay) + const now = _ctx.currentTime; + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(CONFIG.baseVolume, now + 0.01); + gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration); + + // Configure panner for 3D audio + if (CONFIG.enablePositionalAudio && avatarId && _avatars[avatarId]) { + const avatarPos = _avatars[avatarId].position; + + // Set panner properties + panner.panningModel = 'HRTF'; + panner.distanceModel = 'inverse'; + panner.refDistance = CONFIG.referenceDistance; + panner.maxDistance = CONFIG.maxHearingDistance; + panner.rolloffFactor = CONFIG.rolloff; + panner.coneInnerAngle = 360; + panner.coneOuterAngle = 0; + panner.coneOuterGain = 0; + + // Set position relative to listener + if (_camera) { + const cameraPos = _camera.position; + panner.setPosition( + avatarPos.x - cameraPos.x, + avatarPos.y - cameraPos.y, + avatarPos.z - cameraPos.z + ); + } + + // Connect: oscillator -> gain -> panner -> master -> destination + oscillator.connect(gainNode); + gainNode.connect(panner); + panner.connect(_masterGain); + } else { + // Non-positional: oscillator -> gain -> master -> destination + oscillator.connect(gainNode); + gainNode.connect(_masterGain); + } + + // Store source for cleanup + const sourceId = `${type}_${Date.now()}`; + _sources[sourceId] = { oscillator, gainNode, panner }; + + // Start and stop oscillator + oscillator.start(now); + oscillator.stop(now + duration); + + // Clean up after sound finishes + oscillator.onended = () => { + oscillator.disconnect(); + gainNode.disconnect(); + if (panner) panner.disconnect(); + delete _sources[sourceId]; + }; + + // Log notification + console.log(`[SpatialChat] Playing ${type} notification${avatarId ? ` for avatar ${avatarId}` : ''}`); + + return sourceId; + } + + function playChatNotification(avatarId = null) { + return playNotification('chat', avatarId); + } + + function playVoiceNotification(avatarId = null) { + return playNotification('voice', avatarId); + } + + // ─── AVATAR MANAGEMENT ────────────────────────────────── + + function addAvatar(avatarId, position) { + _avatars[avatarId] = { position: position.clone() }; + } + + function updateAvatarPosition(avatarId, position) { + if (_avatars[avatarId]) { + _avatars[avatarId].position.copy(position); + } + } + + function removeAvatar(avatarId) { + delete _avatars[avatarId]; + } + + // ─── DISTANCE CALCULATIONS ────────────────────────────── + + function getDistanceToAvatar(avatarId) { + if (!_camera || !_avatars[avatarId]) return Infinity; + + const avatarPos = _avatars[avatarId].position; + const cameraPos = _camera.position; + + return cameraPos.distanceTo(avatarPos); + } + + function getVolumeForDistance(distance) { + if (distance <= CONFIG.referenceDistance) return 1.0; + if (distance >= CONFIG.maxHearingDistance) return 0.0; + + // Inverse distance rolloff + const t = (distance - CONFIG.referenceDistance) / (CONFIG.maxHearingDistance - CONFIG.referenceDistance); + return Math.pow(1 - t, CONFIG.rolloff); + } + + function isAvatarAudible(avatarId) { + const distance = getDistanceToAvatar(avatarId); + return distance < CONFIG.maxHearingDistance; + } + + // ─── UPDATE LOOP ────────────────────────────────────── + + function update(delta) { + if (!_initialized || !_enabled) return; + + // Update listener position/orientation + if (_listener && _camera) { + // Three.js AudioListener automatically follows camera + } + } + + // ─── CONFIGURATION ────────────────────────────────────── + + function setConfig(newConfig) { + Object.assign(CONFIG, newConfig); + console.log('[SpatialChat] Configuration updated'); + } + + function getConfig() { + return { ...CONFIG }; + } + + function setEnabled(enabled) { + _enabled = enabled; + console.log(`[SpatialChat] ${enabled ? 'Enabled' : 'Disabled'}`); + } + + function isEnabled() { + return _enabled; + } + + function setMasterVolume(volume) { + if (_masterGain) { + _masterGain.gain.value = Math.max(0, Math.min(1, volume)); + } + } + + // ─── PUBLIC API ────────────────────────────────────── + + return { + init, + bindAvatars, + playNotification, + playChatNotification, + playVoiceNotification, + addAvatar, + updateAvatarPosition, + removeAvatar, + getDistanceToAvatar, + getVolumeForDistance, + isAvatarAudible, + update, + setConfig, + getConfig, + setEnabled, + isEnabled, + setMasterVolume, + CONFIG + }; +})(); + +// Export for use in app.js +if (typeof module !== 'undefined' && module.exports) { + module.exports = SpatialChat; +} \ No newline at end of file diff --git a/style.css b/style.css index 961e1a9a..190104ba 100644 --- a/style.css +++ b/style.css @@ -1302,6 +1302,78 @@ canvas#nexus-canvas { background: rgba(74, 240, 192, 0.1); } +/* Spatial Chat Styles */ +.spatial-chat-indicator { + position: fixed; + top: var(--space-4); + left: var(--space-4); + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: rgba(0, 0, 0, 0.3); + border-radius: var(--panel-radius); + font-size: 10px; + color: var(--color-text-muted); + z-index: 100; + pointer-events: none; +} + +.spatial-chat-indicator.enabled { + color: var(--color-primary); +} + +.spatial-chat-indicator.disabled { + color: var(--color-danger); +} + +.spatial-chat-icon { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-primary); +} + +.spatial-chat-icon.disabled { + background: var(--color-danger); +} + +.spatial-chat-distance { + font-family: var(--font-mono); + font-size: 9px; +} + +.spatial-chat-controls { + position: fixed; + bottom: var(--space-4); + left: var(--space-4); + display: flex; + gap: var(--space-2); + z-index: 100; +} + +.spatial-chat-btn { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--color-border); + border-radius: var(--panel-radius); + padding: var(--space-2) var(--space-3); + color: var(--color-text); + font-size: 10px; + cursor: pointer; + transition: all var(--transition-ui); +} + +.spatial-chat-btn:hover { + background: rgba(74, 240, 192, 0.1); + border-color: var(--color-primary); +} + +.spatial-chat-btn.active { + background: rgba(74, 240, 192, 0.2); + border-color: var(--color-primary); + color: var(--color-primary); +} + /* === FOOTER === */ .nexus-footer { position: fixed;