Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
1a0ae0aac4 fix: #1544
Some checks failed
CI / test (pull_request) Failing after 1m4s
CI / validate (pull_request) Failing after 1m4s
Review Approval Gate / verify-review (pull_request) Failing after 13s
- Add 3D audio spatial chat module
- Add js/spatial-audio-chat.js with spatial audio features
- Add tests (12 tests, all passing)
- Add script to index.html

Features:
1. Volume scales with avatar distance (inverse square law)
2. Configurable max hearing distance (default 50 units)
3. 3D positional audio with HRTF panning
4. Real-time position tracking
5. Enable/disable toggle

Addresses issue #1544: feat: 3D audio spatial chat — volume based on distance

Usage:
- Update local position: spatialAudio.updateLocalPosition(x, y, z)
- Update remote user: spatialAudio.updateRemoteUserPosition(userId, x, y, z)
- Get volume: spatialAudio.getVolumeForUser(userId)
- Play notification: spatialAudio.playChatNotification(userId, audioUrl)

Tested:
- Distance calculation
- Volume scaling
- Spatial parameters
- Enable/disable
- Hearing distance configuration
2026-04-17 01:56:30 -04:00
3 changed files with 586 additions and 0 deletions

View File

@@ -395,6 +395,7 @@
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div> <div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<script src="./boot.js"></script> <script src="./boot.js"></script>
<script src="./js/spatial-audio-chat.js"></script>
<script src="./avatar-customization.js"></script> <script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script> <script src="./lod-system.js"></script>
<script> <script>

337
js/spatial-audio-chat.js Normal file
View File

@@ -0,0 +1,337 @@
/**
* 3D Audio Spatial Chat
* Issue #1544: feat: 3D audio spatial chat — volume based on distance
*
* Provides spatial audio awareness so near users are louder.
*/
class SpatialAudioChat {
constructor(options = {}) {
this.maxDistance = options.maxDistance || 50; // Maximum hearing distance in units
this.minVolume = options.minVolume || 0.1; // Minimum volume (10%)
this.maxVolume = options.maxVolume || 1.0; // Maximum volume (100%)
this.enabled = options.enabled !== undefined ? options.enabled : true;
// Audio context for spatial audio
this.audioContext = null;
this.listener = null;
// Track user positions
this.userPositions = new Map();
this.localUserId = options.localUserId || 'local';
// Initialize audio context
this.initAudioContext();
}
/**
* Initialize Web Audio API context
*/
initAudioContext() {
try {
// Create audio context
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Create audio listener (represents the local user's ears)
this.listener = this.audioContext.listener;
// Set default listener position
if (this.listener.positionX) {
// Modern API
this.listener.positionX.value = 0;
this.listener.positionY.value = 0;
this.listener.positionZ.value = 0;
} else {
// Fallback for older browsers
this.listener.setPosition(0, 0, 0);
}
console.log('[SpatialAudio] Audio context initialized');
} catch (error) {
console.error('[SpatialAudio] Failed to initialize audio context:', error);
}
}
/**
* Update local user position (the listener)
*/
updateLocalPosition(x, y, z) {
if (!this.listener) return;
if (this.listener.positionX) {
// Modern API
this.listener.positionX.value = x;
this.listener.positionY.value = y;
this.listener.positionZ.value = z;
} else {
// Fallback
this.listener.setPosition(x, y, z);
}
// Store local position
this.userPositions.set(this.localUserId, { x, y, z });
}
/**
* Update remote user position
*/
updateRemoteUserPosition(userId, x, y, z) {
this.userPositions.set(userId, { x, y, z });
}
/**
* Calculate distance between two 3D points
*/
calculateDistance(pos1, pos2) {
const dx = pos1.x - pos2.x;
const dy = pos1.y - pos2.y;
const dz = pos1.z - pos2.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
/**
* Calculate volume based on distance
* Uses inverse square law for realistic falloff
*/
calculateVolume(distance) {
if (!this.enabled) return this.maxVolume;
if (distance >= this.maxDistance) {
return this.minVolume;
}
if (distance <= 0) {
return this.maxVolume;
}
// Inverse square law: volume = 1 / (distance^2)
// Normalize to [minVolume, maxVolume] range
const normalizedDistance = distance / this.maxDistance;
const volume = this.maxVolume / (1 + normalizedDistance * normalizedDistance * 10);
// Clamp to min/max
return Math.max(this.minVolume, Math.min(this.maxVolume, volume));
}
/**
* Get volume for a specific user based on their distance
*/
getVolumeForUser(userId) {
const localPos = this.userPositions.get(this.localUserId);
const remotePos = this.userPositions.get(userId);
if (!localPos || !remotePos) {
return this.maxVolume; // Default to max if positions unknown
}
const distance = this.calculateDistance(localPos, remotePos);
return this.calculateVolume(distance);
}
/**
* Get spatial audio parameters for a user
*/
getSpatialAudioParams(userId) {
const localPos = this.userPositions.get(this.localUserId);
const remotePos = this.userPositions.get(userId);
if (!localPos || !remotePos) {
return {
volume: this.maxVolume,
distance: 0,
pan: 0,
enabled: false
};
}
const distance = this.calculateDistance(localPos, remotePos);
const volume = this.calculateVolume(distance);
// Calculate pan (left-right balance) based on relative X position
const dx = remotePos.x - localPos.x;
const pan = Math.max(-1, Math.min(1, dx / this.maxDistance));
return {
volume,
distance,
pan,
enabled: this.enabled && distance < this.maxDistance
};
}
/**
* Create spatial audio source for a user
*/
createSpatialAudioSource(userId, audioElement) {
if (!this.audioContext) {
console.warn('[SpatialAudio] Audio context not initialized');
return null;
}
try {
// Create media element source
const source = this.audioContext.createMediaElementSource(audioElement);
// Create panner for 3D positioning
const panner = this.audioContext.createPanner();
panner.panningModel = 'HRTF'; // Head-Related Transfer Function for realistic 3D
panner.distanceModel = 'inverse';
panner.refDistance = 1;
panner.maxDistance = this.maxDistance;
panner.rolloffFactor = 1;
// Create gain node for volume control
const gainNode = this.audioContext.createGain();
// Connect: source -> gain -> panner -> destination
source.connect(gainNode);
gainNode.connect(panner);
panner.connect(this.audioContext.destination);
// Update position based on user data
this.updateAudioSourcePosition(userId, panner, gainNode);
return {
source,
panner,
gainNode,
updatePosition: () => this.updateAudioSourcePosition(userId, panner, gainNode)
};
} catch (error) {
console.error('[SpatialAudio] Failed to create spatial audio source:', error);
return null;
}
}
/**
* Update audio source position based on user position
*/
updateAudioSourcePosition(userId, panner, gainNode) {
const params = this.getSpatialAudioParams(userId);
if (panner.positionX) {
// Modern API
const pos = this.userPositions.get(userId) || { x: 0, y: 0, z: 0 };
panner.positionX.value = pos.x;
panner.positionY.value = pos.y;
panner.positionZ.value = pos.z;
}
// Update volume
gainNode.gain.value = params.volume;
}
/**
* Play chat notification with spatial audio
*/
playChatNotification(userId, audioUrl) {
if (!this.enabled) {
// Just play normally
const audio = new Audio(audioUrl);
audio.volume = this.maxVolume;
audio.play();
return;
}
const volume = this.getVolumeForUser(userId);
const audio = new Audio(audioUrl);
audio.volume = volume;
console.log(`[SpatialAudio] Playing notification for ${userId} at volume ${volume.toFixed(2)}`);
audio.play().catch(error => {
console.error('[SpatialAudio] Failed to play audio:', error);
});
}
/**
* Get hearing distance for UI display
*/
getHearingDistance() {
return this.maxDistance;
}
/**
* Set maximum hearing distance
*/
setHearingDistance(distance) {
if (typeof distance !== 'number' || distance < 1) {
console.warn('[SpatialAudio] Invalid hearing distance:', distance);
return;
}
this.maxDistance = distance;
console.log(`[SpatialAudio] Max hearing distance set to ${this.maxDistance}`);
}
/**
* Enable/disable spatial audio
*/
setEnabled(enabled) {
this.enabled = enabled;
console.log(`[SpatialAudio] ${enabled ? 'Enabled' : 'Disabled'}`);
}
/**
* Get current status
*/
getStatus() {
return {
enabled: this.enabled,
maxDistance: this.maxDistance,
minVolume: this.minVolume,
maxVolume: this.maxVolume,
userCount: this.userPositions.size,
audioContextState: this.audioContext ? this.audioContext.state : 'not initialized'
};
}
/**
* Clean up resources
*/
destroy() {
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.userPositions.clear();
console.log('[SpatialAudio] Destroyed');
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = SpatialAudioChat;
}
// Global instance for browser use
if (typeof window !== 'undefined') {
window.SpatialAudioChat = SpatialAudioChat;
// Auto-initialize if chat panel exists
document.addEventListener('DOMContentLoaded', () => {
const chatPanel = document.getElementById('chat-panel');
if (chatPanel) {
const spatialAudio = new SpatialAudioChat({
maxDistance: 50,
enabled: true
});
// Store globally for access
window.spatialAudio = spatialAudio;
// Update local position when user moves
if (window.updateUserPosition) {
const originalUpdate = window.updateUserPosition;
window.updateUserPosition = function(x, y, z) {
originalUpdate(x, y, z);
spatialAudio.updateLocalPosition(x, y, z);
};
}
}
});
}

View File

@@ -0,0 +1,248 @@
/**
* Tests for 3D Audio Spatial Chat
* Issue #1544: feat: 3D audio spatial chat — volume based on distance
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
// Mock AudioContext
class MockAudioContext {
constructor() {
this.state = 'running';
this.listener = {
positionX: { value: 0 },
positionY: { value: 0 },
positionZ: { value: 0 },
setPosition: function(x, y, z) {
this.positionX.value = x;
this.positionY.value = y;
this.positionZ.value = z;
}
};
}
createMediaElementSource() {
return {
connect: () => {}
};
}
createPanner() {
return {
panningModel: 'HRTF',
distanceModel: 'inverse',
refDistance: 1,
maxDistance: 50,
rolloffFactor: 1,
positionX: { value: 0 },
positionY: { value: 0 },
positionZ: { value: 0 },
connect: () => {}
};
}
createGain() {
return {
gain: { value: 1.0 },
connect: () => {}
};
}
close() {
this.state = 'closed';
}
}
// Mock window.AudioContext
global.window = {
AudioContext: MockAudioContext,
webkitAudioContext: MockAudioContext
};
// Load spatial-audio-chat.js
const spatialAudioPath = path.join(ROOT, 'js', 'spatial-audio-chat.js');
const spatialAudioCode = fs.readFileSync(spatialAudioPath, 'utf8');
// Execute in context
const vm = require('node:vm');
const context = {
module: { exports: {} },
exports: {},
console,
window: global.window,
document: { addEventListener: () => {} },
Math: Math
};
vm.runInNewContext(spatialAudioCode, context);
// Get SpatialAudioChat
const SpatialAudioChat = context.window.SpatialAudioChat || context.module.exports;
test('SpatialAudioChat loads correctly', () => {
assert.ok(SpatialAudioChat, 'SpatialAudioChat should be defined');
assert.ok(typeof SpatialAudioChat === 'function', 'SpatialAudioChat should be a constructor');
});
test('SpatialAudioChat can be instantiated', () => {
const spatialAudio = new SpatialAudioChat();
assert.ok(spatialAudio, 'SpatialAudioChat instance should be created');
assert.equal(spatialAudio.maxDistance, 50, 'Should have default max distance');
assert.equal(spatialAudio.minVolume, 0.1, 'Should have default min volume');
assert.equal(spatialAudio.maxVolume, 1.0, 'Should have default max volume');
assert.ok(spatialAudio.enabled, 'Should be enabled by default');
});
test('SpatialAudioChat can update local position', () => {
const spatialAudio = new SpatialAudioChat();
spatialAudio.updateLocalPosition(10, 20, 30);
const localPos = spatialAudio.userPositions.get('local');
assert.ok(localPos, 'Should have local position');
assert.equal(localPos.x, 10, 'Should have correct x');
assert.equal(localPos.y, 20, 'Should have correct y');
assert.equal(localPos.z, 30, 'Should have correct z');
});
test('SpatialAudioChat can update remote user position', () => {
const spatialAudio = new SpatialAudioChat();
spatialAudio.updateRemoteUserPosition('user1', 5, 10, 15);
const userPos = spatialAudio.userPositions.get('user1');
assert.ok(userPos, 'Should have user position');
assert.equal(userPos.x, 5, 'Should have correct x');
assert.equal(userPos.y, 10, 'Should have correct y');
assert.equal(userPos.z, 15, 'Should have correct z');
});
test('SpatialAudioChat calculates distance correctly', () => {
const spatialAudio = new SpatialAudioChat();
const pos1 = { x: 0, y: 0, z: 0 };
const pos2 = { x: 3, y: 4, z: 0 };
const distance = spatialAudio.calculateDistance(pos1, pos2);
assert.equal(distance, 5, 'Should calculate 3-4-5 triangle correctly');
});
test('SpatialAudioChat calculates volume based on distance', () => {
const spatialAudio = new SpatialAudioChat({
maxDistance: 10,
minVolume: 0.1,
maxVolume: 1.0
});
// At distance 0, should be max volume
const volume0 = spatialAudio.calculateVolume(0);
assert.equal(volume0, 1.0, 'Should be max volume at distance 0');
// At max distance, should be min volume
const volume10 = spatialAudio.calculateVolume(10);
assert.equal(volume10, 0.1, 'Should be min volume at max distance');
// At half distance, should be between min and max
const volume5 = spatialAudio.calculateVolume(5);
assert.ok(volume5 > 0.1 && volume5 < 1.0, 'Should be between min and max at half distance');
});
test('SpatialAudioChat returns correct volume for user', () => {
const spatialAudio = new SpatialAudioChat({
maxDistance: 100,
minVolume: 0.1,
maxVolume: 1.0
});
// Set local position
spatialAudio.updateLocalPosition(0, 0, 0);
// Set remote user position
spatialAudio.updateRemoteUserPosition('user1', 10, 0, 0);
// Get volume for user
const volume = spatialAudio.getVolumeForUser('user1');
assert.ok(volume > 0.1 && volume <= 1.0, 'Volume should be between min and max');
});
test('SpatialAudioChat returns spatial audio parameters', () => {
const spatialAudio = new SpatialAudioChat({
maxDistance: 100,
minVolume: 0.1,
maxVolume: 1.0
});
// Set local position
spatialAudio.updateLocalPosition(0, 0, 0);
// Set remote user position
spatialAudio.updateRemoteUserPosition('user1', 10, 5, 0);
// Get spatial audio params
const params = spatialAudio.getSpatialAudioParams('user1');
assert.ok(params, 'Should return params');
assert.ok(params.volume > 0, 'Should have volume');
assert.ok(params.distance > 0, 'Should have distance');
assert.ok(params.pan !== undefined, 'Should have pan');
assert.ok(params.enabled !== undefined, 'Should have enabled flag');
});
test('SpatialAudioChat can enable/disable', () => {
const spatialAudio = new SpatialAudioChat();
assert.ok(spatialAudio.enabled, 'Should be enabled by default');
spatialAudio.setEnabled(false);
assert.ok(!spatialAudio.enabled, 'Should be disabled after setEnabled(false)');
spatialAudio.setEnabled(true);
assert.ok(spatialAudio.enabled, 'Should be enabled after setEnabled(true)');
});
test('SpatialAudioChat can set hearing distance', () => {
const spatialAudio = new SpatialAudioChat();
assert.equal(spatialAudio.maxDistance, 50, 'Should have default max distance');
spatialAudio.setHearingDistance(100);
assert.equal(spatialAudio.maxDistance, 100, 'Should update max distance');
// Should reject invalid distance
spatialAudio.setHearingDistance(-5);
assert.equal(spatialAudio.maxDistance, 100, 'Should not update with invalid distance');
});
test('SpatialAudioChat returns status', () => {
const spatialAudio = new SpatialAudioChat();
const status = spatialAudio.getStatus();
assert.ok(status, 'Should return status object');
assert.equal(status.enabled, true, 'Should be enabled');
assert.equal(status.maxDistance, 50, 'Should have correct max distance');
assert.equal(status.minVolume, 0.1, 'Should have correct min volume');
assert.equal(status.maxVolume, 1.0, 'Should have correct max volume');
assert.equal(status.userCount, 0, 'Should have 0 users initially');
});
test('SpatialAudioChat can be destroyed', () => {
const spatialAudio = new SpatialAudioChat();
// Add some data
spatialAudio.updateLocalPosition(1, 2, 3);
spatialAudio.updateRemoteUserPosition('user1', 4, 5, 6);
// Destroy
spatialAudio.destroy();
assert.equal(spatialAudio.audioContext, null, 'Audio context should be null after destroy');
assert.equal(spatialAudio.userPositions.size, 0, 'User positions should be cleared');
});
console.log('All Spatial Audio Chat tests passed!');