Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4cfb2c49 |
10
app.js
10
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);
|
||||
|
||||
269
nexus/components/spatial-chat.js
Normal file
269
nexus/components/spatial-chat.js
Normal file
@@ -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;
|
||||
}
|
||||
72
style.css
72
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;
|
||||
|
||||
Reference in New Issue
Block a user