Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
5ff0257c0f feat: 3D audio spatial chat — volume based on distance (fix #1544)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 11s
CI / test (pull_request) Failing after 1m14s
CI / validate (pull_request) Failing after 1m17s
- Add SpatialChatAudio component with distance-based volume rolloff
- Each agent gets distinct tone (freq + waveform)
- Stereo panning based on relative position to camera
- HRTF PannerNode support for voice chat streams
- Integrate with addChatMessage for spatial notifications
- Tests + docs included
2026-04-22 02:55:20 -04:00
13 changed files with 451 additions and 1743 deletions

153
app.js
View File

@@ -5,6 +5,7 @@ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
import { SpatialMemory } from './nexus/components/spatial-memory.js';
import { SpatialAudio } from './nexus/components/spatial-audio.js';
import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
import { MemoryBirth } from './nexus/components/memory-birth.js';
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
import { MemoryInspect } from './nexus/components/memory-inspect.js';
@@ -104,13 +105,7 @@ const orbitState = {
let flyY = 2;
// ══ POV CAMERA SYSTEM ══
let povMode = false; // true when viewing through agent's eyes
let povAgentIdx = -1; // index into agents[] for POV target (-1 = none)
let savedCameraState = null; // { position: Vector3, rotation: Euler } to restore on exit
const DEFAULT_AGENT_FOV = 75; // default field-of-view for agent POV cameras
// ╡══ INIT ══╡
// ═══ INIT ══
import {
SymbolicEngine, AgentFSM, KnowledgeGraph, Blackboard,
@@ -722,9 +717,7 @@ async function init() {
// Initialize avatar and LOD systems
if (window.AvatarCustomization) window.AvatarCustomization.init(scene, camera);
if (window.LODSystem) window.LODSystem.init(scene, camera, renderer);
if (window.PerformanceMonitor) window.PerformanceMonitor.init();
if (window.TextureOptimizer) window.TextureOptimizer.init();
if (window.LODSystem) window.LODSystem.init(scene, camera);
updateLoad(20);
@@ -776,6 +769,7 @@ async function init() {
SpatialMemory.setCamera(camera);
SpatialAudio.init(camera, scene);
SpatialAudio.bindSpatialMemory(SpatialMemory);
SpatialChatAudio.init(camera);
MemoryInspect.init({ onNavigate: _navigateToMemory });
MemoryPulse.init(SpatialMemory);
ReasoningTrace.init();
@@ -1342,10 +1336,10 @@ function updateNexusCommand(state) {
// ═══ AGENT PRESENCE SYSTEM ═══
function createAgentPresences() {
const agentData = [
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 }, fov: 70 },
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 }, fov: 80 },
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 }, fov: 65 },
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 }, fov: 90 },
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 } },
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 } },
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 } },
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 } },
];
agentData.forEach(data => {
@@ -1401,8 +1395,7 @@ function createAgentPresences() {
color,
station: data.station,
targetPos: new THREE.Vector3(data.pos.x, 0, data.pos.z),
wanderTimer: 0,
fov: data.fov || DEFAULT_AGENT_FOV,
wanderTimer: 0
});
});
}
@@ -1966,97 +1959,7 @@ function updateNavModeUI(mode) {
if (el) el.textContent = mode.toUpperCase();
}
// ══ AGENT POV CAMERA TOGGLE ══
function toggleAgentPOV() {
if (!agents.length) {
addChatMessage('system', 'No agents present to observe.');
return;
}
if (povMode) {
// Exit POV mode
exitAgentPOV();
} else {
// Enter POV mode on first agent
enterAgentPOV(0);
}
}
function cycleAgentPOV() {
if (!agents.length) return;
if (!povMode) {
enterAgentPOV(0);
return;
}
const nextIdx = (povAgentIdx + 1) % agents.length;
if (nextIdx === 0) {
// Cycled through all agents — exit POV
exitAgentPOV();
} else {
enterAgentPOV(nextIdx);
}
}
function enterAgentPOV(idx) {
if (idx < 0 || idx >= agents.length) return;
// Save current camera state before switching
if (!povMode) {
savedCameraState = {
position: camera.position.clone(),
rotation: camera.rotation.clone(),
fov: camera.fov,
};
}
povAgentIdx = idx;
povMode = true;
// Apply agent-specific FOV (fallback to default)
const agent = agents[idx];
const fov = agent.fov || DEFAULT_AGENT_FOV;
camera.fov = fov;
camera.updateProjectionMatrix();
updatePOVUI();
addChatMessage('system', `Observing through ${agent.id.toUpperCase()}'s eyes. FOV: ${fov}°`);
}
function exitAgentPOV() {
if (!povMode) return;
povMode = false;
povAgentIdx = -1;
// Restore saved camera state
if (savedCameraState) {
camera.position.copy(savedCameraState.position);
camera.rotation.copy(savedCameraState.rotation);
camera.fov = savedCameraState.fov;
camera.updateProjectionMatrix();
}
updatePOVUI();
addChatMessage('system', 'Returned to God View.');
}
function updatePOVUI() {
const label = document.getElementById('pov-label');
const btn = document.getElementById('pov-toggle-btn');
if (!label || !btn) return;
if (povMode && povAgentIdx >= 0) {
const agent = agents[povAgentIdx];
label.textContent = agent.id.toUpperCase();
btn.classList.add('pov-active');
} else {
label.textContent = 'AGENT POV';
btn.classList.remove('pov-active');
}
}
// ╡══ CONTROLS ══╡
// ══ CONTROLS ══
function setupControls() {
document.addEventListener('keydown', (e) => {
keys[e.key.toLowerCase()] = true;
@@ -2083,9 +1986,6 @@ function setupControls() {
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
cycleNavMode();
}
if (e.key.toLowerCase() === 'p' && document.activeElement !== document.getElementById('chat-input')) {
cycleAgentPOV();
}
if (e.key.toLowerCase() === 'f' && activePortal && !portalOverlayActive) {
activatePortal(activePortal);
}
@@ -2235,7 +2135,6 @@ function setupControls() {
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);
document.getElementById('mode-toggle-btn').addEventListener('click', toggleUIMode);
document.getElementById('pov-toggle-btn').addEventListener('click', cycleAgentPOV);
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
initAtlasControls();
@@ -3069,6 +2968,17 @@ function loadSession() {
function addChatMessage(agent, text, shouldSave = true) {
// Mine chat messages for MemPalace
mineMemPalaceContent(text);
// 3D spatial audio notification (issue #1544)
if (window.SpatialChatAudio && agent !== 'system') {
// Find agent position from tracked agents or fallback to origin
let pos = { x: 0, y: 0, z: 0 };
const agentEntry = Array.isArray(window._trackedAgents) && window._trackedAgents.find(a => a.name === agent);
if (agentEntry && agentEntry.position) {
pos = agentEntry.position;
}
window.SpatialChatAudio.playChatSound(agent, new THREE.Vector3(pos.x, pos.y, pos.z));
}
// Mine chat messages for MemPalace
mineMemPalaceContent(text);
const container = document.getElementById('chat-messages');
@@ -3473,21 +3383,7 @@ function gameLoop() {
const mode = NAV_MODES[navModeIdx];
const chatActive = document.activeElement === document.getElementById('chat-input');
// Agent POV mode overrides other camera modes
if (povMode && povAgentIdx >= 0 && agents[povAgentIdx]) {
const agent = agents[povAgentIdx];
const orbPos = agent.orb.getWorldPosition(new THREE.Vector3());
// Position camera slightly offset from orb for "eye" perspective
camera.position.copy(orbPos);
camera.position.y += 0.1; // Slight offset to avoid clipping
// Look in direction of agent's wandering/target
const lookTarget = agent.targetPos.clone();
lookTarget.y = camera.position.y;
camera.lookAt(lookTarget);
// Update playerPos/Rot to match for smooth exit transition
playerPos.copy(camera.position);
playerRot.y = Math.atan2(lookTarget.x - camera.position.x, lookTarget.z - camera.position.z);
} else if (mode === 'walk') {
if (mode === 'walk') {
if (!chatActive && !portalOverlayActive) {
const speed = 6 * delta;
const dir = new THREE.Vector3();
@@ -3681,11 +3577,6 @@ function gameLoop() {
if (composer) { composer.render(); } else { renderer.render(scene, camera); }
// Update performance monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.update(renderer, scene, camera);
}
// Update avatar and LOD systems
if (window.AvatarCustomization && playerPos) window.AvatarCustomization.update(playerPos);
if (window.LODSystem && playerPos) window.LODSystem.update(playerPos);

View File

@@ -1,131 +0,0 @@
# Minimum Sovereign Hardware Requirements
## The Nexus — Three.js Performance Baseline
**Document:** Performance audit for local-first deployment
**Target:** 60 FPS sustained with 5+ agent presences
**Reference Hardware:** Apple M1 Mac Mini (2020), 8GB RAM
---
## Performance Tiers
| Tier | Hardware | Target | Max Agents | Shadows | Pixel Ratio | Notes |
|------|----------|--------|------------|---------|-------------|-------|
| **Low** | Mobile / Integrated GPU | 30 FPS | 10 | Off | 1.0 | iPhone, Intel UHD |
| **Medium** | M1 Mac / Mid-range | **60 FPS** | 25 | Basic | 1.5 | **Baseline standard** |
| **High** | M2+/Dedicated GPU | 60+ FPS | 50+ | Soft PCF | 2.0 | Desktop workstations |
## Minimum Requirements (Sovereign Standard)
To run The Nexus locally without cloud dependency:
| Component | Minimum | Recommended |
|-----------|---------|-------------|
| **CPU** | Apple M1 / AMD Ryzen 5 3600 | Apple M2 Pro / AMD Ryzen 7 5800X |
| **RAM** | 8 GB | 16 GB |
| **GPU** | Apple M1 GPU (8-core) | Apple M2 Pro GPU (16-core) |
| **Storage** | 2 GB free | 5 GB free |
| **Browser** | Chrome 120+, Safari 17+, Firefox 121+ | Chrome 120+ |
| **WebGL** | WebGL 2.0 | WebGL 2.0 |
## Performance Optimizations Applied
### 1. Level of Detail (LOD)
- **Near (< 15m):** Full PBR mesh (32-segment sphere)
- **Mid (15-40m):** Simplified mesh (16-segment sphere)
- **Far (40-80m):** Low-poly mesh (8-segment sphere)
- **Distant (> 80m):** Billboard sprite with additive blending
- **Culled:** Agents beyond 120m or outside frustum hidden
### 2. Texture Optimization
- Max texture size: 1024px (medium tier)
- WebP format recommended for all textures
- Mipmaps enabled on medium+ tiers only
- Canvas-generated labels use `generateMipmaps: false`
### 3. Renderer Settings by Tier
```javascript
// Low (mobile/integrated)
renderer.setPixelRatio(1);
renderer.shadowMap.enabled = false;
renderer.toneMapping = THREE.NoToneMapping;
// Medium (M1 Mac baseline)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.BasicShadowMap;
// High (dedicated GPU)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
```
### 4. Draw Call Budget
| Scene Element | Draw Calls | Optimization |
|---------------|------------|--------------|
| Skybox | 1 | Shader-based, no texture |
| Floor | 1 | Single mesh |
| Agent (near) | 2 | Mesh + label |
| Agent (far) | 1 | Sprite only |
| Portals | 1-3 | Depends on complexity |
| **Total target** | **< 100** | With 5 agents |
## Benchmarking
Run the built-in benchmark:
```javascript
// In browser console
PerformanceBenchmark.run(10000); // 10-second test
PerformanceBenchmark.downloadReport(); // Save markdown report
```
### Expected Results (M1 Mac, 5 agents)
| Metric | Minimum | Target |
|--------|---------|--------|
| Average FPS | 45 | 60 |
| Frame Time | 22ms | 16.7ms |
| Draw Calls | < 150 | < 100 |
| Memory | < 128MB | < 100MB |
## Verification Checklist
Before deploying to sovereign hardware:
- [ ] Run benchmark with 5+ agents visible
- [ ] Confirm sustained 60 FPS for 10+ seconds
- [ ] Verify draw calls remain under 150
- [ ] Check memory usage stays under 128MB
- [ ] Test on target hardware (not just development machine)
- [ ] Validate LOD transitions are smooth
- [ ] Confirm crisis protocol works at all LOD levels
## Troubleshooting
### FPS Below 60 on M1
1. Check `PerformanceMonitor` (Shift+P) for draw calls
2. Reduce `LODSystem` thresholds: `LODSystem.forceTier('low')`
3. Disable post-processing effects
4. Reduce agent count or draw distance
### High Memory Usage
1. Run `TextureOptimizer.auditScene(scene)`
2. Check for uncompressed textures
3. Reduce texture size limits: `TextureOptimizer.SIZE_LIMITS.medium = 512`
### Stuttering
1. Check for garbage collection pauses
2. Reduce object creation in render loop
3. Enable `renderer.info.autoReset = false` and manual reset
---
*Sovereignty and service always.*

View File

@@ -1,98 +0,0 @@
# Nostr Migration Consolidation Plan
> Issue #862 | Canonical Epic: the-nexus #819
> Consolidated From: the-nexus #819 + timmy-config #138
---
## Problem
Two epics tracked the same Telegram -> Nostr migration with overlapping scope:
| Epic | Repo | Focus | Status |
|------|------|-------|--------|
| #819 | the-nexus | Client fork (Nostur), UI/UX, agent presence | **CANONICAL** |
| #138 | timmy-config | Relay/infrastructure, deployment, ops | Tracked child |
Neither was the parent. Work risked duplication and drift.
---
## Resolution
**#819 is the canonical parent epic.** All Nostr migration work rolls up here.
### Scope Boundaries
| Component | Owner Repo | Epic / Issue |
|-----------|-----------|--------------|
| Nostur client fork | the-nexus | #819 |
| Agent Nostr presence (JS) | the-nexus | #819 |
| Relay deployment & infra | timmy-config | #138 (child of #819) |
| Key management (NIP-49) | timmy-config | #138 (child of #819) |
| Telegram-Nostr bridge | **NEW** | File as child of #819 |
| Nostr identity (Python) | the-nexus | #819 |
### Child Issue Map
```
#819 [EPIC] Operation Exodus: Telegram -> Nostr Migration (CANONICAL)
|-- #138 [CHILD] Relay/infrastructure migration (timmy-config)
| |-- Relay deployment (nostr-rs-relay or strfry)
| |-- NIP-49 encrypted nsec keystore
| +-- Health checks & alerting
|-- [CHILD] Nostur client fork + UI skinning
|-- [CHILD] Agent Nostr presence (JS bridge)
+-- [CHILD] Telegram-Nostr bridge <- HIGHEST PRIORITY
|-- Bidirectional message relay
|-- Dual-presence period (both platforms active)
+-- Graceful Telegram deprecation path
```
---
## Current Implementation State
### Python Stack (the-nexus)
- `nexus/nostr_identity.py` - Pure-Python BIP340 Schnorr signatures
- WARNING **Timing side-channel vulnerabilities** (see FINDINGS-issue-801.md)
- Suitable for prototyping; production needs `coincurve` or constant-time rewrite
- `nexus/nostr_publisher.py` - Async WebSocket publisher to public relays
### Browser Stack (the-nexus)
- `app.js:NostrAgent` - Browser-side agent presence
- WARNING Uses **mock signatures** (`mock_id`, `mock_sig`)
- Needs real crypto integration or delegation to Python backend
### Infrastructure (timmy-config)
- `nostr-bridge.service` - Running but source file deleted, only `.pyc` remains
- `/root/nostr-relay/keystore.json` - NIP-49 encrypted nsec storage
---
## Highest Priority: Telegram-Nostr Bridge
The bridge is the critical path. Without it, migration strands users on Telegram.
**Requirements:**
1. Bidirectional message relay (Telegram <-> Nostr)
2. Dual-presence period: both platforms active during transition
3. Graceful deprecation: Telegram bot stays online until 90% of active users have Nostr handles
4. Channel/topic mapping: preserve conversation structure
**File this as a new child issue under #819.**
---
## Action Items
- [ ] Close #138 in timmy-config with comment: "Consolidated into the-nexus #819. Relay/infrastructure work tracked as child of canonical epic."
- [ ] Update #819 title/body to reference this consolidation plan
- [ ] File child issue: Telegram-Nostr bridge (bidirectional, dual-presence)
- [ ] File child issue: Fix timing side-channel in `nostr_identity.py` (or replace with `coincurve`)
- [ ] File child issue: Replace mock signatures in `app.js:NostrAgent` with real crypto
- [ ] Assign owners to each child issue
---
*Sovereignty and service always.*

View File

@@ -1,140 +0,0 @@
# Telegram-Nostr Bridge Specification
> Child of Epic #819 (Operation Exodus: Telegram -> Nostr Migration)
> Priority: HIGHEST
---
## Overview
Bidirectional message relay between Telegram and Nostr during the migration period.
Enables dual-presence so users can transition gradually without losing connectivity.
---
## Requirements
### Functional
1. **Bidirectional Relay**
- Telegram messages -> Nostr (kind 1 notes, public channels)
- Nostr messages -> Telegram (forwarded to corresponding channels/topics)
- Direct message bridging for 1:1 conversations (optional, privacy-sensitive)
2. **Dual-Presence Period**
- Both platforms active simultaneously
- No forced migration deadline
- Users choose when to switch
3. **Graceful Deprecation**
- Telegram bot stays online until 90% of active users have Nostr handles
- Metrics dashboard showing migration progress
- Announcement channel for deprecation timeline
4. **Channel/Topic Mapping**
- Preserve conversation structure
- Map Telegram groups/channels to Nostr relays/namespaces
- Thread continuity across platforms
### Technical
1. **Nostr Side**
- Publish to configured relays (damus.io, nos.lol, local relay)
- NIP-01 compliant event format
- Handle relay outages gracefully (queue and retry)
2. **Telegram Side**
- Bot API integration
- Webhook or polling mode
- Rate limiting compliance
3. **Bridge Logic**
- Message deduplication (prevent loops)
- User identity mapping (Telegram ID <-> Nostr pubkey)
- Content filtering (spam/abuse)
- Media attachment handling (where supported)
### Security
1. **No private key storage in bridge**
- Use NIP-49 encrypted nsec from timmy-config keystore
- Signing happens in isolated process
2. **Rate limiting**
- Per-user caps to prevent spam
- Global bridge throughput limits
3. **Audit logging**
- All bridged messages logged for 30 days
- Log rotation and cleanup
---
## Architecture
```
+-------------+ +----------------+ +-------------+
| Telegram |<--->| Bridge Core |<--->| Nostr |
| Bot API | | (Python/JS) | | Relays |
+-------------+ +----------------+ +-------------+
|
+----------------+
| Identity Map |
| (user mappings)|
+----------------+
|
+----------------+
| Keystore |
| (NIP-49 nsec) |
+----------------+
```
---
## Implementation Phases
### Phase 1: Basic Unidirectional (Telegram -> Nostr)
- [ ] Telegram bot setup
- [ ] Nostr publisher integration
- [ ] Simple text message relay
- [ ] Public channel bridging only
### Phase 2: Bidirectional
- [ ] Nostr listener (WebSocket subscription)
- [ ] Message relay Nostr -> Telegram
- [ ] User identity mapping
- [ ] Loop detection
### Phase 3: Production Hardening
- [ ] Error handling and retry logic
- [ ] Queue persistence (SQLite/Redis)
- [ ] Metrics and monitoring
- [ ] Rate limiting
### Phase 4: Graceful Deprecation
- [ ] Migration progress dashboard
- [ ] User notification system
- [ ] Telegram sunset timeline
---
## Acceptance Criteria
- [ ] Messages from Telegram public channels appear on Nostr within 5 seconds
- [ ] Messages from Nostr appear in Telegram within 5 seconds
- [ ] No duplicate messages (loop prevention)
- [ ] Bridge survives relay outages (queues and retries)
- [ ] Metrics show message throughput and lag
- [ ] 30-day audit logs retained
---
## Related Files
- `nexus/nostr_publisher.py` - Nostr publishing (reusable)
- `nexus/nostr_identity.py` - Signing (needs hardening)
- `docs/nostr-migration/CONSOLIDATION.md` - Parent epic context
---
*Part of Operation Exodus.*

View File

@@ -0,0 +1,68 @@
# Spatial Chat Audio — 3D Audio for Chat Messages
Refs: the-nexus #1544
## Overview
Adds spatial awareness to chat notifications so nearby users/agents sound louder.
Volume scales with avatar distance from the camera.
## Features
### Chat Notification Sounds
- Each agent has a distinct tone (frequency + waveform)
- Volume decreases with distance (inverse rolloff)
- Stereo panning based on relative position to camera
- Sounds auto-cleanup after playback
### 3D Positional Voice (WebRTC-ready)
- `createVoiceSource()` returns a PannerNode for real voice streams
- HRTF panning model for realistic 3D positioning
- Update position in real-time as avatars move
### Configurable Parameters
- `maxHearingDistance` — max distance to hear sounds (default: 40)
- `refDistance` — full volume within this range (default: 5)
- `rolloffFactor` — volume falloff curve (default: 1.5)
- `baseVolume` — master volume cap (default: 0.3)
## Usage
```javascript
import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
// Initialize with camera
SpatialChatAudio.init(camera);
// Set max hearing distance
SpatialChatAudio.setMaxHearingDistance(50);
// Play a chat sound when a message arrives
// position = avatar/agent position in 3D world
SpatialChatAudio.playChatSound('timmy', agentPosition);
// For voice chat: create a persistent 3D source
const voice = SpatialChatAudio.createVoiceSource('user', avatarPosition);
// Update as avatar moves
voice.updatePosition(newPosition);
// Cleanup when disconnected
voice.destroy();
```
## Agent Sound Profiles
| Agent | Frequency | Waveform |
|--------|-----------|------------|
| timmy | 440 Hz | sine |
| user | 523 Hz | sine |
| system | 330 Hz | triangle |
| kimi | 659 Hz | sine |
| claude | 392 Hz | sine |
| grok | 587 Hz | triangle |
| gemini | 494 Hz | sine |
## Testing
```bash
node tests/test_spatial_chat_audio.js
```

View File

@@ -173,10 +173,6 @@
<span class="hud-icon">👁</span>
<span class="hud-btn-label" id="mode-label">VISITOR</span>
</button>
<button id="pov-toggle-btn" class="hud-icon-btn" title="Agent POV Camera">
<span class="hud-icon">👁</span>
<span class="hud-btn-label" id="pov-label">AGENT POV</span>
</button>
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<span class="hud-icon">🌐</span>
<span class="hud-btn-label">WORLDS</span>
@@ -233,7 +229,6 @@
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
&nbsp; <span>P</span> agent POV &nbsp;
&nbsp; <span>H</span> archive &nbsp;
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
</div>
@@ -407,9 +402,6 @@
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./performance-monitor.js"></script>
<script src="./texture-optimizer.js"></script>
<script src="./performance-benchmark.js"></script>
<script src="./portal-hot-reload.js"></script>
<script src="./cockpit-inspector.js"></script>
<script>

View File

@@ -1,424 +0,0 @@
/**
* Enhanced LOD (Level of Detail) System for The Nexus
*
* Optimizes rendering for local hardware sovereignty:
* - THREE.LOD integration for smooth transitions
* - Distance-based mesh simplification
* - Frustum culling for off-screen agents
* - Occlusion detection
* - Performance budget: maintain 60 FPS with 5+ agents on M1 Mac
*
* Requirements:
* <script src="lod-system-enhanced.js"></script>
* LODSystem.init(scene, camera, renderer);
* LODSystem.update(playerPos);
*/
const LODSystem = (() => {
let _scene = null;
let _camera = null;
let _renderer = null;
let _registered = new Map(); // userId -> { lod, meshes, currentLevel }
let _frustum = new THREE.Frustum();
let _projScreenMatrix = new THREE.Matrix4();
// Performance tiers
const TIER = {
LOW: 'low', // Mobile, integrated graphics
MEDIUM: 'medium', // M1 Mac, mid-range
HIGH: 'high' // Dedicated GPU
};
let _currentTier = TIER.MEDIUM;
// LOD thresholds by tier
const LOD_THRESHOLDS = {
[TIER.LOW]: {
near: 10,
mid: 25,
far: 50,
cull: 80
},
[TIER.MEDIUM]: {
near: 15,
mid: 40,
far: 80,
cull: 120
},
[TIER.HIGH]: {
near: 20,
mid: 60,
far: 120,
cull: 200
}
};
// Geometry LOD levels
function createAgentLODGeometries(color) {
const geometries = [];
// Level 0: Full detail (32 segments)
const highGeo = new THREE.SphereGeometry(0.4, 32, 32);
geometries.push(highGeo);
// Level 1: Medium detail (16 segments)
const midGeo = new THREE.SphereGeometry(0.4, 16, 16);
geometries.push(midGeo);
// Level 2: Low detail (8 segments)
const lowGeo = new THREE.SphereGeometry(0.4, 8, 8);
geometries.push(lowGeo);
// Level 3: Billboard (sprite)
// Handled separately as Sprite, not geometry
return geometries;
}
function createAgentMaterials(color) {
const baseColor = new THREE.Color(color);
return [
// Level 0: Full PBR material
new THREE.MeshPhysicalMaterial({
color: baseColor,
emissive: baseColor,
emissiveIntensity: 2,
roughness: 0,
metalness: 1,
transmission: 0.8,
thickness: 0.5,
clearcoat: 1,
clearcoatRoughness: 0.1
}),
// Level 1: Simplified PBR
new THREE.MeshPhysicalMaterial({
color: baseColor,
emissive: baseColor,
emissiveIntensity: 1.5,
roughness: 0.1,
metalness: 0.8,
transmission: 0.5
}),
// Level 2: Basic material
new THREE.MeshBasicMaterial({
color: baseColor,
transparent: true,
opacity: 0.9
})
];
}
function createBillboardSprite(color) {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
// Gradient orb
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 28);
const hexColor = '#' + new THREE.Color(color).getHexString();
gradient.addColorStop(0, hexColor);
gradient.addColorStop(0.7, hexColor + 'aa');
gradient.addColorStop(1, 'transparent');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(32, 32, 28, 0, Math.PI * 2);
ctx.fill();
// Inner glow
ctx.fillStyle = hexColor;
ctx.beginPath();
ctx.arc(32, 32, 12, 0, Math.PI * 2);
ctx.fill();
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false; // Save memory for sprites
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: true,
sizeAttenuation: true,
blending: THREE.AdditiveBlending
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(1.2, 1.2, 1);
return sprite;
}
function detectPerformanceTier() {
const gl = document.createElement('canvas').getContext('webgl');
if (!gl) return TIER.LOW;
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (!debugInfo) return TIER.MEDIUM;
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
// Detect Apple Silicon
if (renderer.includes('Apple')) {
return renderer.includes('M3') || renderer.includes('M2') || renderer.includes('M1 Pro')
? TIER.HIGH : TIER.MEDIUM;
}
// Detect integrated graphics
if (renderer.includes('Intel') && !renderer.includes('Arc')) {
return TIER.LOW;
}
// Mobile detection
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
return TIER.LOW;
}
return TIER.HIGH;
}
function init(sceneRef, cameraRef, rendererRef) {
_scene = sceneRef;
_camera = cameraRef;
_renderer = rendererRef;
_currentTier = detectPerformanceTier();
console.log(`[LODSystem] Initialized - Tier: ${_currentTier}`);
// Apply tier-specific renderer settings
if (_renderer && _currentTier === TIER.LOW) {
_renderer.setPixelRatio(1);
_renderer.shadowMap.enabled = false;
} else if (_renderer && _currentTier === TIER.MEDIUM) {
_renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
_renderer.shadowMap.type = THREE.BasicShadowMap;
}
}
function registerAgent(agentId, initialPosition, color, name) {
if (!_scene) return null;
const lod = new THREE.LOD();
lod.position.copy(initialPosition);
lod.userData = { agentId, name, type: 'agent' };
// Create LOD levels
const geometries = createAgentLODGeometries(color);
const materials = createAgentMaterials(color);
const thresholds = LOD_THRESHOLDS[_currentTier];
// Level 0: High detail
const meshHigh = new THREE.Mesh(geometries[0], materials[0]);
meshHigh.castShadow = _currentTier !== TIER.LOW;
meshHigh.receiveShadow = _currentTier !== TIER.LOW;
lod.addLevel(meshHigh, 0);
// Level 1: Medium detail
const meshMid = new THREE.Mesh(geometries[1], materials[1]);
lod.addLevel(meshMid, thresholds.near);
// Level 2: Low detail
const meshLow = new THREE.Mesh(geometries[2], materials[2]);
lod.addLevel(meshLow, thresholds.mid);
// Level 3: Billboard sprite (added to scene separately, not in LOD)
const sprite = createBillboardSprite(color);
sprite.position.copy(initialPosition);
sprite.position.y += 0.4;
sprite.visible = false;
_scene.add(sprite);
// Label
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 256;
labelCanvas.height = 64;
const ctx = labelCanvas.getContext('2d');
ctx.font = 'bold 24px "Orbitron", sans-serif';
ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
ctx.textAlign = 'center';
ctx.fillText(name, 128, 40);
const labelTex = new THREE.CanvasTexture(labelCanvas);
labelTex.minFilter = THREE.LinearFilter;
labelTex.generateMipmaps = false;
const labelMat = new THREE.MeshBasicMaterial({
map: labelTex,
transparent: true,
side: THREE.DoubleSide
});
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 0.5), labelMat);
labelMesh.position.set(0, 0.8, 0);
labelMesh.visible = true;
lod.add(labelMesh);
_scene.add(lod);
_registered.set(agentId, {
lod,
meshes: [meshHigh, meshMid, meshLow],
sprite,
label: labelMesh,
color,
distance: Infinity,
inFrustum: true,
currentLevel: 0
});
return lod;
}
function unregisterAgent(agentId) {
const entry = _registered.get(agentId);
if (!entry) return;
_scene.remove(entry.lod);
_scene.remove(entry.sprite);
// Dispose geometries and materials
entry.meshes.forEach(mesh => {
mesh.geometry.dispose();
mesh.material.dispose();
});
entry.sprite.material.map.dispose();
entry.sprite.material.dispose();
entry.label.material.map.dispose();
entry.label.material.dispose();
_registered.delete(agentId);
}
function update(playerPos) {
if (!_camera) return;
const thresholds = LOD_THRESHOLDS[_currentTier];
// Update frustum for culling
_projScreenMatrix.multiplyMatrices(
_camera.projectionMatrix,
_camera.matrixWorldInverse
);
_frustum.setFromProjectionMatrix(_projScreenMatrix);
_registered.forEach((entry, agentId) => {
const lodPos = entry.lod.position;
const distance = playerPos.distanceTo(lodPos);
entry.distance = distance;
// Frustum culling
const inFrustum = _frustum.containsPoint(lodPos);
entry.inFrustum = inFrustum;
// Distance culling
if (distance > thresholds.cull || !inFrustum) {
entry.lod.visible = false;
entry.sprite.visible = false;
return;
}
entry.lod.visible = true;
// THREE.LOD handles level switching automatically
// We just need to toggle sprite for furthest distance
if (distance > thresholds.far) {
entry.lod.visible = false;
entry.sprite.visible = true;
entry.sprite.position.copy(lodPos);
entry.sprite.position.y += 0.4;
entry.currentLevel = 3;
} else {
entry.sprite.visible = false;
entry.currentLevel = entry.lod.getCurrentLevel();
}
// Make label always face camera
entry.label.lookAt(_camera.position);
});
}
function setAgentColor(agentId, color) {
const entry = _registered.get(agentId);
if (!entry) return;
entry.color = color;
const materials = createAgentMaterials(color);
entry.meshes.forEach((mesh, i) => {
mesh.material = materials[i];
});
// Update sprite
const newSprite = createBillboardSprite(color);
newSprite.position.copy(entry.sprite.position);
newSprite.visible = entry.sprite.visible;
_scene.remove(entry.sprite);
entry.sprite.material.map.dispose();
entry.sprite.material.dispose();
entry.sprite = newSprite;
_scene.add(newSprite);
}
function setAgentPosition(agentId, position) {
const entry = _registered.get(agentId);
if (!entry) return;
entry.lod.position.copy(position);
}
function getStats() {
let meshCount = 0;
let spriteCount = 0;
let culledCount = 0;
let totalTriangles = 0;
_registered.forEach(entry => {
if (entry.sprite.visible) {
spriteCount++;
} else if (entry.lod.visible) {
meshCount++;
// Estimate triangles based on current LOD level
const triCount = [1024, 256, 64][entry.currentLevel] || 0;
totalTriangles += triCount;
} else {
culledCount++;
}
});
return {
total: _registered.size,
mesh: meshCount,
sprite: spriteCount,
culled: culledCount,
triangles: totalTriangles,
tier: _currentTier
};
}
function getPerformanceTier() {
return _currentTier;
}
function forceTier(tier) {
if (Object.values(TIER).includes(tier)) {
_currentTier = tier;
console.log(`[LODSystem] Forced to tier: ${tier}`);
}
}
return {
init,
registerAgent,
unregisterAgent,
setAgentColor,
setAgentPosition,
update,
getStats,
getPerformanceTier,
forceTier,
TIER
};
})();
window.LODSystem = LODSystem;

View File

@@ -0,0 +1,236 @@
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
// SPATIAL CHAT AUDIO — 3D Audio for Chat Messages (issue #1544)
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
//
// Volume scales with avatar distance — closer agents sound louder.
// 3D positional audio places chat sounds in world space.
//
// Usage from app.js:
// import { SpatialChatAudio } from './nexus/components/spatial-chat-audio.js';
// SpatialChatAudio.init(camera);
// SpatialChatAudio.playChatSound('timmy', agentPosition);
//
// Configuration:
// SpatialChatAudio.setMaxHearingDistance(50); // default 40 units
// SpatialChatAudio.setEnabled(true/false);
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
const SpatialChatAudio = (() => {
// ─── CONFIG ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
let _config = {
maxHearingDistance: 40, // Distance at which volume reaches 0
refDistance: 5, // Full volume within this range
rolloffFactor: 1.5, // Volume rolloff curve
baseVolume: 0.3, // Master volume for chat sounds
enabled: true,
};
// ─── STATE ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
let _camera = null;
let _listener = null;
let _ctx = null;
let _masterGain = null;
let _initialized = false;
// Agent sound profiles (frequency + waveform)
const AGENT_SOUNDS = {
timmy: { freq: 440, type: 'sine' }, // A4 - clear
user: { freq: 523, type: 'sine' }, // C5 - higher
system: { freq: 330, type: 'triangle' }, // E4 - neutral
kimi: { freq: 659, type: 'sine' }, // E5 - bright
claude: { freq: 392, type: 'sine' }, // G4 - warm
grok: { freq: 587, type: 'triangle' }, // D5 - sharp
gemini: { freq: 494, type: 'sine' }, // B4 - balanced
default: { freq: 440, type: 'sine' }, // A4 - default
};
// ─── INIT ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function init(camera) {
if (_initialized) return;
_camera = camera;
// Create or reuse AudioListener from camera
if (!_listener) {
_listener = new THREE.AudioListener();
camera.add(_listener);
} else {
_listener = camera.getObjectByProperty('type', 'AudioListener') || _listener;
}
// Get audio context from listener
_ctx = _listener.context;
_masterGain = _ctx.createGain();
_masterGain.gain.value = _config.baseVolume;
_masterGain.connect(_ctx.destination);
_initialized = true;
console.info('[SpatialChatAudio] Initialized — max hearing distance:', _config.maxHearingDistance);
// Resume context if suspended (browser autoplay policy)
if (_ctx.state === 'suspended') {
const resume = () => {
_ctx.resume().then(() => {
console.info('[SpatialChatAudio] AudioContext resumed');
document.removeEventListener('click', resume);
document.removeEventListener('keydown', resume);
});
};
document.addEventListener('click', resume);
document.addEventListener('keydown', resume);
}
return _listener;
}
// ─── PLAY CHAT SOUND ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function playChatSound(agent, position) {
if (!_initialized || !_config.enabled) return;
const sound = AGENT_SOUNDS[agent] || AGENT_SOUNDS.default;
const camPos = _camera.position;
// Calculate distance
const dx = position.x - camPos.x;
const dy = position.y - camPos.y;
const dz = position.z - camPos.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
// Calculate volume based on distance
let volume = 0;
if (dist < _config.maxHearingDistance) {
volume = 1 / (1 + _config.rolloffFactor * (dist - _config.refDistance));
volume = Math.max(0, Math.min(1, volume));
}
// Skip if too quiet
if (volume < 0.01) return;
// Create audio nodes
const osc = _ctx.createOscillator();
osc.type = sound.type;
osc.frequency.value = sound.freq;
const gain = _ctx.createGain();
gain.gain.value = volume;
const panner = _ctx.createStereoPanner();
// Calculate stereo panning
const camRight = new THREE.Vector3();
_camera.getWorldDirection(camRight);
camRight.cross(_camera.up).normalize();
const toSource = new THREE.Vector3(dx, 0, dz).normalize();
const pan = THREE.MathUtils.clamp(toSource.dot(camRight), -1, 1);
panner.pan.value = pan;
// Connect and play
osc.connect(gain);
gain.connect(panner);
panner.connect(_masterGain);
// Short envelope (attack + decay)
const now = _ctx.currentTime;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(volume, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
osc.start(now);
osc.stop(now + 0.35);
// Cleanup
osc.onended = () => {
osc.disconnect();
gain.disconnect();
panner.disconnect();
};
console.debug(`[SpatialChatAudio] ${agent} at ${dist.toFixed(1)}m, vol=${volume.toFixed(2)}, pan=${pan.toFixed(2)}`);
}
// ─── CONFIGURATION ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function setMaxHearingDistance(dist) {
_config.maxHearingDistance = Math.max(5, dist);
console.info('[SpatialChatAudio] Max hearing distance:', _config.maxHearingDistance);
}
function getMaxHearingDistance() {
return _config.maxHearingDistance;
}
function setEnabled(enabled) {
_config.enabled = enabled;
console.info('[SpatialChatAudio]', enabled ? 'Enabled' : 'Disabled');
}
function isEnabled() {
return _config.enabled;
}
function setMasterVolume(vol) {
if (_masterGain) {
_masterGain.gain.setTargetAtTime(
THREE.MathUtils.clamp(vol, 0, 1),
_ctx.currentTime,
0.05
);
}
}
// ─── VOICE CHAT SUPPORT (WebRTC placeholder) ─────────────────────────────────────────────────────────────────────────────────────────
function createVoiceSource(agentId, position) {
if (!_initialized) return null;
// Create a PannerNode for 3D voice positioning
const panner = _ctx.createPanner();
panner.panningModel = 'HRTF';
panner.distanceModel = 'inverse';
panner.refDistance = _config.refDistance;
panner.maxDistance = _config.maxHearingDistance;
panner.rolloffFactor = _config.rolloffFactor;
// Set initial position
panner.positionX.value = position.x;
panner.positionY.value = position.y;
panner.positionZ.value = position.z;
// Connect to master
panner.connect(_masterGain);
console.info(`[SpatialChatAudio] Voice source created for ${agentId}`);
return {
panner,
agentId,
updatePosition(pos) {
panner.positionX.setValueAtTime(pos.x, _ctx.currentTime);
panner.positionY.setValueAtTime(pos.y, _ctx.currentTime);
panner.positionZ.setValueAtTime(pos.z, _ctx.currentTime);
},
destroy() {
panner.disconnect();
console.info(`[SpatialChatAudio] Voice source destroyed for ${agentId}`);
}
};
}
// ─── API ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
return {
init,
playChatSound,
createVoiceSource,
setMaxHearingDistance,
getMaxHearingDistance,
setEnabled,
isEnabled,
setMasterVolume,
};
})();
// Export for module or global usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = { SpatialChatAudio };
} else if (typeof window !== 'undefined') {
window.SpatialChatAudio = SpatialChatAudio;
}

View File

@@ -1,269 +0,0 @@
/**
* Performance Benchmark for The Nexus
*
* Runs automated performance tests and generates a report:
* - FPS stability test with 5+ agents
* - Draw call count validation
* - Frame time analysis
* - Memory usage tracking
*
* Usage:
* PerformanceBenchmark.run();
* PerformanceBenchmark.generateReport();
*/
const PerformanceBenchmark = (() => {
let _isRunning = false;
let _results = {
fps: [],
frameTime: [],
drawCalls: [],
timestamps: [],
agentCount: 0,
tier: 'unknown',
duration: 0
};
let _startTime = 0;
let _rafId = null;
function init() {
console.log('[PerformanceBenchmark] Initialized');
}
async function run(duration = 10000) {
if (_isRunning) return;
_isRunning = true;
console.log(`[PerformanceBenchmark] Starting ${duration}ms test...`);
// Show performance monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.show();
}
// Get current tier
if (window.LODSystem) {
_results.tier = window.LODSystem.getPerformanceTier();
}
// Get agent count
if (window.agents) {
_results.agentCount = window.agents.length;
}
// Reset data
_results = {
fps: [],
frameTime: [],
drawCalls: [],
timestamps: [],
agentCount: _results.agentCount,
tier: _results.tier,
duration: duration
};
_startTime = performance.now();
return new Promise((resolve) => {
let lastTime = performance.now();
let frameCount = 0;
function measure() {
const now = performance.now();
const elapsed = now - _startTime;
if (elapsed >= duration) {
finish();
resolve(_results);
return;
}
// Calculate FPS
frameCount++;
if (now - lastTime >= 1000) {
const fps = Math.round((frameCount * 1000) / (now - lastTime));
_results.fps.push(fps);
_results.timestamps.push(Math.round(elapsed));
// Get draw calls
if (window.renderer && window.renderer.info) {
_results.drawCalls.push(window.renderer.info.render.calls);
}
frameCount = 0;
lastTime = now;
}
_rafId = requestAnimationFrame(measure);
}
measure();
});
}
function finish() {
_isRunning = false;
if (_rafId) cancelAnimationFrame(_rafId);
// Calculate statistics
const fps = _results.fps;
const avgFps = fps.reduce((a, b) => a + b, 0) / fps.length;
const minFps = Math.min(...fps);
const maxFps = Math.max(...fps);
_results.summary = {
averageFPS: Math.round(avgFps),
minFPS: minFps,
maxFPS: maxFps,
targetMet: avgFps >= 60,
stability: ((avgFps - minFps) / avgFps * 100).toFixed(1) + '%'
};
console.log('[PerformanceBenchmark] Complete:', _results.summary);
// Hide monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.hide();
}
// Show results overlay
showResultsOverlay();
}
function showResultsOverlay() {
const div = document.createElement('div');
div.id = 'perf-benchmark-results';
div.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(10, 15, 26, 0.95);
border: 1px solid #00ffcc;
padding: 2rem;
border-radius: 8px;
color: #e0e0ff;
font-family: 'Orbitron', monospace;
z-index: 10001;
min-width: 300px;
`;
const s = _results.summary;
const targetStatus = s.targetMet
? '<span style="color: #00ffcc">✓ TARGET MET</span>'
: '<span style="color: #ff4444">✗ BELOW TARGET</span>';
div.innerHTML = `
<h3 style="margin: 0 0 1rem 0; color: #00ffcc;">Performance Benchmark</h3>
<div style="margin-bottom: 0.5rem;"><strong>Tier:</strong> ${_results.tier}</div>
<div style="margin-bottom: 0.5rem;"><strong>Agents:</strong> ${_results.agentCount}</div>
<div style="margin-bottom: 0.5rem;"><strong>Average FPS:</strong> ${s.averageFPS}</div>
<div style="margin-bottom: 0.5rem;"><strong>Min/Max FPS:</strong> ${s.minFPS} / ${s.maxFPS}</div>
<div style="margin-bottom: 0.5rem;"><strong>Stability:</strong> ${s.stability} variance</div>
<div style="margin: 1rem 0; padding: 0.5rem; background: rgba(0,0,0,0.3); border-radius: 4px;">
<strong>60 FPS Target:</strong> ${targetStatus}
</div>
<button onclick="this.parentElement.remove()" style="
background: #00ffcc;
color: #0a0f1a;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
">Close</button>
`;
document.body.appendChild(div);
}
function generateReport() {
const r = _results;
const s = r.summary || {};
const report = `
# The Nexus Performance Benchmark Report
**Date:** ${new Date().toISOString()}
**Duration:** ${r.duration}ms
**Performance Tier:** ${r.tier}
**Agent Count:** ${r.agentCount}
## Results
| Metric | Value | Target | Status |
|--------|-------|--------|--------|
| Average FPS | ${s.averageFPS || 'N/A'} | ≥ 60 | ${s.targetMet ? '✓ PASS' : '✗ FAIL'} |
| Minimum FPS | ${s.minFPS || 'N/A'} | ≥ 45 | ${(s.minFPS >= 45) ? '✓ PASS' : '✗ FAIL'} |
| Stability | ${s.stability || 'N/A'} | < 20% | ${parseFloat(s.stability) < 20 ? '✓ PASS' : '✗ FAIL'} |
## Hardware Requirements Met
${getHardwareRequirements(r)}
## Recommendations
${getRecommendations(r)}
`;
return report;
}
function getHardwareRequirements(results) {
const tier = results.tier;
const passed = results.summary?.targetMet;
if (passed) {
return `- Current hardware (${tier} tier) meets minimum sovereign requirements
- Suitable for deployment on similar hardware
- Consider lowering settings for mobile/integrated graphics`;
} else {
return `- Current hardware (${tier} tier) BELOW minimum requirements
- Recommend: M1 Mac or equivalent for 60 FPS
- Consider: Reduce agent count, disable shadows, lower resolution`;
}
}
function getRecommendations(results) {
const recs = [];
if (!results.summary?.targetMet) {
recs.push('- Enable LOD system if not active');
recs.push('- Reduce shadow map resolution or disable shadows');
recs.push('- Lower pixel ratio to 1.0');
recs.push('- Reduce agent draw distance');
}
if (results.drawCalls.some(c => c > 1000)) {
recs.push('- High draw call count detected - consider batching geometry');
}
if (results.agentCount < 5) {
recs.push('- Test with 5+ agents for full validation');
}
return recs.length ? recs.join('\n') : '- Performance optimal - no action required';
}
function downloadReport() {
const report = generateReport();
const blob = new Blob([report], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nexus-performance-report-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
}
return {
init,
run,
generateReport,
downloadReport,
get results() { return _results; }
};
})();
window.PerformanceBenchmark = PerformanceBenchmark;

View File

@@ -1,272 +0,0 @@
/**
* Performance Monitor for The Nexus
*
* Integrates Three.js Stats.js overlay with custom metrics:
* - FPS counter
* - Frame time (ms)
* - Draw calls
* - Agent LOD stats
*
* Usage:
* PerformanceMonitor.init();
* PerformanceMonitor.update(); // call in render loop
* PerformanceMonitor.show();
* PerformanceMonitor.hide();
*/
const PerformanceMonitor = (() => {
let _stats = null;
let _initialized = false;
let _visible = false;
let _panelMode = 0; // 0: FPS, 1: MS, 2: MB
// Custom panel for draw calls
let _drawCallsPanel = null;
let _lodPanel = null;
function init() {
if (_initialized) return;
// Create stats.js panels
_stats = new Stats();
_stats.showPanel(0); // 0: fps, 1: ms, 2: mb
_stats.dom.style.cssText = 'position:fixed;top:10px;left:10px;z-index:10000;';
_stats.dom.style.display = 'none'; // Hidden by default
// Add custom draw calls panel
_drawCallsPanel = _stats.addPanel(new Stats.Panel('DRAW', '#ff8', '#221'));
// Add custom LOD panel
_lodPanel = _stats.addPanel(new Stats.Panel('AGENTS', '#8ff', '#122'));
document.body.appendChild(_stats.dom);
_initialized = true;
// Add keyboard shortcut (Shift+P)
document.addEventListener('keydown', (e) => {
if (e.shiftKey && e.key === 'P') {
toggle();
}
if (_visible && e.key === ' ') {
e.preventDefault();
nextPanel();
}
});
console.log('[PerformanceMonitor] Initialized. Press Shift+P to toggle, Space to cycle panels.');
}
function show() {
if (!_initialized) init();
_stats.dom.style.display = 'block';
_visible = true;
}
function hide() {
if (_stats) {
_stats.dom.style.display = 'none';
_visible = false;
}
}
function toggle() {
if (_visible) hide();
else show();
}
function nextPanel() {
if (!_stats) return;
_panelMode = (_panelMode + 1) % 5;
_stats.showPanel(_panelMode);
}
function update(renderer, scene, camera) {
if (!_stats || !_visible) return;
_stats.begin();
// Update draw calls info
if (renderer && renderer.info) {
const info = renderer.info.render;
_drawCallsPanel.update(info.calls, 1000);
}
// Update LOD stats
if (window.LODSystem) {
const lodStats = window.LODSystem.getStats();
const total = lodStats.total || 1;
const active = lodStats.mesh + lodStats.sprite;
_lodPanel.update(active, total);
}
_stats.end();
}
function getSnapshot() {
const snapshot = {
timestamp: Date.now(),
fps: _stats ? _stats.fps : 0,
renderer: null,
lod: null
};
if (typeof renderer !== 'undefined' && renderer && renderer.info) {
snapshot.renderer = {
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
points: renderer.info.render.points,
lines: renderer.info.render.lines
};
}
if (window.LODSystem) {
snapshot.lod = window.LODSystem.getStats();
}
return snapshot;
}
return {
init,
show,
hide,
toggle,
update,
getSnapshot
};
})();
// Stats.js library (inline for self-containment)
// From: https://github.com/mrdoob/stats.js
(function(global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Stats = factory());
}(this, function() {
var Stats = function() {
var mode = 0;
var container = document.createElement('div');
container.style.cssText = 'position:fixed;top:0;left:0;cursor:pointer;opacity:0.9;z-index:10000';
container.addEventListener('click', function(event) {
event.preventDefault();
showPanel(++mode % container.children.length);
}, false);
function addPanel(panel) {
container.appendChild(panel.dom);
return panel;
}
function showPanel(id) {
for (var i = 0; i < container.children.length; i++) {
container.children[i].style.display = i === id ? 'block' : 'none';
}
mode = id;
}
var beginTime = (performance || Date).now();
var prevTime = beginTime;
var frames = 0;
var fpsPanel = addPanel(new Stats.Panel('FPS', '#0ff', '#002'));
var msPanel = addPanel(new Stats.Panel('MS', '#0f0', '#020'));
if (self.performance && self.performance.memory) {
var memPanel = addPanel(new Stats.Panel('MB', '#f08', '#201'));
}
showPanel(0);
return {
REVISION: 16,
dom: container,
addPanel: addPanel,
showPanel: showPanel,
begin: function() {
beginTime = (performance || Date).now();
},
end: function() {
frames++;
var time = (performance || Date).now();
msPanel.update(time - beginTime, 200);
if (time >= prevTime + 1000) {
fpsPanel.update((frames * 1000) / (time - prevTime), 100);
prevTime = time;
frames = 0;
if (memPanel) {
var memory = performance.memory;
memPanel.update(memory.usedJSHeapSize / 1048576, memory.jsHeapSizeLimit / 1048576);
}
}
return time;
},
update: function() {
beginTime = this.end();
},
// Expose fps for getSnapshot
get fps() {
return fpsPanel ? fpsPanel._value : 0;
}
};
};
Stats.Panel = function(name, fg, bg) {
var min = Infinity, max = 0, round = Math.round;
var PR = round(window.devicePixelRatio || 1);
var WIDTH = 80 * PR, HEIGHT = 48 * PR,
TEXT_X = 3 * PR, TEXT_Y = 2 * PR,
GRAPH_X = 3 * PR, GRAPH_Y = 15 * PR,
GRAPH_WIDTH = 74 * PR, GRAPH_HEIGHT = 30 * PR;
var canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.cssText = 'width:80px;height:48px';
var context = canvas.getContext('2d');
context.font = 'bold ' + (9 * PR) + 'px Helvetica,Arial,sans-serif';
context.textBaseline = 'top';
context.fillStyle = bg;
context.fillRect(0, 0, WIDTH, HEIGHT);
context.fillStyle = fg;
context.fillText(name, TEXT_X, TEXT_Y);
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = bg;
context.globalAlpha = 0.9;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
return {
dom: canvas,
_value: 0,
update: function(value, maxValue) {
this._value = value;
min = Math.min(min, value);
max = Math.max(max, value);
context.fillStyle = bg;
context.globalAlpha = 1;
context.fillRect(0, 0, WIDTH, GRAPH_Y);
context.fillStyle = fg;
context.fillText(round(value) + ' ' + name + ' (' + round(min) + '-' + round(max) + ')', TEXT_X, TEXT_Y);
context.globalAlpha = 0.9;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = bg;
context.globalAlpha = 0.1;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = fg;
context.globalAlpha = 1;
context.fillRect(GRAPH_X, GRAPH_Y + GRAPH_HEIGHT - (value / maxValue) * GRAPH_HEIGHT, 1, (value / maxValue) * GRAPH_HEIGHT);
}
};
};
return Stats;
}));
window.PerformanceMonitor = PerformanceMonitor;

View File

@@ -200,13 +200,6 @@ canvas#nexus-canvas {
box-shadow: 0 0 20px var(--color-primary);
}
.hud-icon-btn.pov-active {
background: var(--color-gold);
border-color: var(--color-gold);
color: var(--color-bg);
box-shadow: 0 0 20px var(--color-gold);
}
.hud-status-item {
display: flex;
align-items: center;

View File

@@ -0,0 +1,125 @@
/**
* Tests for SpatialChatAudio component (issue #1544)
*/
import { SpatialChatAudio } from '../nexus/components/spatial-chat-audio.js';
// Mock DOM and THREE for Node.js testing
if (typeof document === 'undefined') {
global.document = {
addEventListener: () => {},
removeEventListener: () => {},
};
}
if (typeof THREE === 'undefined') {
global.THREE = {
Vector3: class {
constructor(x=0, y=0, z=0) { this.x = x; this.y = y; this.z = z; }
normalize() { return this; }
dot() { return 0; }
cross() { return this; }
},
MathUtils: { clamp: (v, min, max) => Math.max(min, Math.min(max, v)) },
AudioListener: class {
constructor() {
this.context = {
state: 'running',
currentTime: 0,
createOscillator: () => ({
type: 'sine',
frequency: { value: 440 },
connect: () => {},
start: () => {},
stop: () => {},
disconnect: () => {},
onended: null,
}),
createGain: () => ({
gain: { value: 1, setValueAtTime: () => {}, linearRampToValueAtTime: () => {}, exponentialRampToValueAtTime: () => {}, setTargetAtTime: () => {} },
connect: () => {},
disconnect: () => {},
}),
createStereoPanner: () => ({
pan: { value: 0, setValueAtTime: () => {}, setTargetAtTime: () => {} },
connect: () => {},
disconnect: () => {},
}),
createPanner: () => ({
panningModel: '',
distanceModel: '',
refDistance: 0,
maxDistance: 0,
rolloffFactor: 0,
positionX: { value: 0, setValueAtTime: () => {} },
positionY: { value: 0, setValueAtTime: () => {} },
positionZ: { value: 0, setValueAtTime: () => {} },
connect: () => {},
disconnect: () => {},
}),
resume: () => Promise.resolve(),
destination: {},
};
}
},
};
}
function assert(condition, message) {
if (!condition) {
console.error(`❌ FAILED: ${message}`);
process.exit(1);
}
console.log(`✔ PASSED: ${message}`);
}
console.log('--- Running SpatialChatAudio Tests ---');
// Test 1: Module exports
assert(typeof SpatialChatAudio === 'object', 'SpatialChatAudio exports an object');
assert(typeof SpatialChatAudio.init === 'function', 'SpatialChatAudio has init method');
assert(typeof SpatialChatAudio.playChatSound === 'function', 'SpatialChatAudio has playChatSound method');
assert(typeof SpatialChatAudio.createVoiceSource === 'function', 'SpatialChatAudio has createVoiceSource method');
// Test 2: Config defaults
assert(SpatialChatAudio.isEnabled() === true, 'Enabled by default');
assert(SpatialChatAudio.getMaxHearingDistance() === 40, 'Default max hearing distance is 40');
// Test 3: Configuration changes
SpatialChatAudio.setMaxHearingDistance(60);
assert(SpatialChatAudio.getMaxHearingDistance() === 60, 'Max hearing distance updated to 60');
SpatialChatAudio.setEnabled(false);
assert(SpatialChatAudio.isEnabled() === false, 'Can disable audio');
SpatialChatAudio.setEnabled(true);
assert(SpatialChatAudio.isEnabled() === true, 'Can re-enable audio');
// Test 4: Initialization with mock camera
const mockCamera = {
position: new THREE.Vector3(0, 0, 0),
getWorldDirection: () => new THREE.Vector3(1, 0, 0),
up: new THREE.Vector3(0, 1, 0),
add: () => {},
getObjectByProperty: () => null,
};
SpatialChatAudio.init(mockCamera);
assert(true, 'SpatialChatAudio initializes with camera');
// Test 5: Voice source creation
const voiceSource = SpatialChatAudio.createVoiceSource('timmy', new THREE.Vector3(10, 0, 0));
assert(voiceSource !== null, 'Voice source created');
assert(voiceSource.agentId === 'timmy', 'Voice source has correct agentId');
assert(typeof voiceSource.updatePosition === 'function', 'Voice source has updatePosition');
assert(typeof voiceSource.destroy === 'function', 'Voice source has destroy');
// Test 6: Voice source position update
voiceSource.updatePosition(new THREE.Vector3(20, 0, 0));
assert(true, 'Voice source position updated');
// Test 7: Voice source cleanup
voiceSource.destroy();
assert(true, 'Voice source destroyed');
console.log('--- All SpatialChatAudio Tests Passed ---');

View File

@@ -1,263 +0,0 @@
/**
* Texture Optimizer for The Nexus
*
* Provides utilities for texture compression and optimization:
* - WebP fallback for browser compatibility
* - Texture size limits based on performance tier
* - Mipmap generation control
* - Memory budget tracking
*
* Usage:
* const tex = TextureOptimizer.load('./assets/texture.png');
* TextureOptimizer.compressTexture(tex, { maxSize: 512, format: 'webp' });
*/
const TextureOptimizer = (() => {
// Size limits by performance tier (max texture dimension)
const SIZE_LIMITS = {
low: 512,
medium: 1024,
high: 2048
};
// Memory budget in MB
const MEMORY_BUDGETS = {
low: 64,
medium: 128,
high: 256
};
let _currentTier = 'medium';
let _textureMemory = 0;
let _textures = new Set();
function detectTier() {
const gl = document.createElement('canvas').getContext('webgl');
if (!gl) return 'low';
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (!debugInfo) return 'medium';
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
if (renderer.includes('Apple')) {
return renderer.includes('M3') || renderer.includes('M2 Pro') || renderer.includes('M1 Pro')
? 'high' : 'medium';
}
if (renderer.includes('Intel') && !renderer.includes('Arc')) return 'low';
if (/Android|webOS|iPhone|iPad|iPod/i.test(navigator.userAgent)) return 'low';
return 'high';
}
function init() {
_currentTier = detectTier();
console.log(`[TextureOptimizer] Tier: ${_currentTier}, Max texture size: ${SIZE_LIMITS[_currentTier]}px`);
}
/**
* Load a texture with automatic optimization
*/
function load(url, options = {}) {
const loader = new THREE.TextureLoader();
const texture = loader.load(url, (tex) => {
optimizeTexture(tex, options);
});
_textures.add(texture);
return texture;
}
/**
* Optimize an existing texture
*/
function optimizeTexture(texture, options = {}) {
const maxSize = options.maxSize || SIZE_LIMITS[_currentTier];
const format = options.format || 'auto';
// Limit texture size
const width = texture.image?.width || texture.image?.videoWidth || 0;
const height = texture.image?.height || texture.image?.videoHeight || 0;
if (width > maxSize || height > maxSize) {
const scale = maxSize / Math.max(width, height);
const newWidth = Math.floor(width * scale);
const newHeight = Math.floor(height * scale);
// Create resized canvas
const canvas = document.createElement('canvas');
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(texture.image, 0, 0, newWidth, newHeight);
texture.image = canvas;
texture.needsUpdate = true;
console.log(`[TextureOptimizer] Resized texture from ${width}x${height} to ${newWidth}x${newHeight}`);
}
// Mipmap settings based on tier
if (_currentTier === 'low') {
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
} else if (_currentTier === 'medium') {
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
}
// Anisotropic filtering
if (options.anisotropy !== undefined) {
texture.anisotropy = options.anisotropy;
} else if (_currentTier === 'low') {
texture.anisotropy = 1;
} else if (_currentTier === 'medium') {
texture.anisotropy = 4;
} else {
texture.anisotropy = 8;
}
// Format conversion hint (for server-side preprocessing)
if (format === 'webp' && !urlEndsWith(texture.image?.src, '.webp')) {
console.warn(`[TextureOptimizer] Consider converting to WebP: ${texture.image?.src}`);
}
// Track memory usage
trackMemory(texture);
return texture;
}
function urlEndsWith(url, suffix) {
return url && url.toLowerCase().endsWith(suffix);
}
function trackMemory(texture) {
const width = texture.image?.width || 0;
const height = texture.image?.height || 0;
const bytesPerPixel = 4; // RGBA
const mipmaps = texture.generateMipmaps ? 1.33 : 1; // Mipmaps add ~33%
const memoryMB = (width * height * bytesPerPixel * mipmaps) / (1024 * 1024);
_textureMemory += memoryMB;
texture.userData.memoryMB = memoryMB;
const budget = MEMORY_BUDGETS[_currentTier];
if (_textureMemory > budget) {
console.warn(`[TextureOptimizer] Memory budget exceeded: ${_textureMemory.toFixed(1)}MB / ${budget}MB`);
cleanupTextures();
}
}
function cleanupTextures() {
const budget = MEMORY_BUDGETS[_currentTier];
if (_textureMemory <= budget) return;
// Sort by last used time (if available)
const sorted = Array.from(_textures).sort((a, b) => {
return (a.userData.lastUsed || 0) - (b.userData.lastUsed || 0);
});
for (const tex of sorted) {
if (_textureMemory <= budget * 0.8) break;
if (tex.userData.memoryMB) {
_textureMemory -= tex.userData.memoryMB;
tex.dispose();
_textures.delete(tex);
}
}
console.log(`[TextureOptimizer] Cleaned up textures. Memory: ${_textureMemory.toFixed(1)}MB`);
}
function getMemoryUsage() {
return {
used: _textureMemory,
budget: MEMORY_BUDGETS[_currentTier],
count: _textures.size
};
}
/**
* Audit all textures in a scene
*/
function auditScene(scene) {
const audit = {
textures: [],
totalMemory: 0,
oversized: [],
uncompressed: []
};
scene.traverse((object) => {
if (object.material) {
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.forEach(mat => {
['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap'].forEach(mapType => {
const texture = mat[mapType];
if (texture && texture.image) {
const width = texture.image.width || 0;
const height = texture.image.height || 0;
const memoryMB = (width * height * 4) / (1024 * 1024);
audit.textures.push({
type: mapType,
size: `${width}x${height}`,
memoryMB: memoryMB.toFixed(2),
url: texture.image.src || 'generated'
});
audit.totalMemory += memoryMB;
if (width > SIZE_LIMITS[_currentTier] || height > SIZE_LIMITS[_currentTier]) {
audit.oversized.push({
type: mapType,
size: `${width}x${height}`,
limit: SIZE_LIMITS[_currentTier]
});
}
const src = texture.image.src || '';
if (src && !src.endsWith('.webp') && !src.endsWith('.ktx2') && !src.endsWith('.basis')) {
audit.uncompressed.push(src);
}
}
});
});
}
});
return audit;
}
/**
* Convert image to WebP (client-side, for runtime generated textures)
*/
async function convertToWebP(imageElement, quality = 0.85) {
const canvas = document.createElement('canvas');
canvas.width = imageElement.width;
canvas.height = imageElement.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageElement, 0, 0);
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/webp', quality);
});
}
return {
init,
load,
optimizeTexture,
getMemoryUsage,
auditScene,
convertToWebP,
SIZE_LIMITS,
MEMORY_BUDGETS
};
})();
window.TextureOptimizer = TextureOptimizer;