Compare commits
1 Commits
mimo/code/
...
fix/1544
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0ae0aac4 |
@@ -395,6 +395,7 @@
|
||||
<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="./js/spatial-audio-chat.js"></script>
|
||||
<script src="./avatar-customization.js"></script>
|
||||
<script src="./lod-system.js"></script>
|
||||
<script>
|
||||
|
||||
337
js/spatial-audio-chat.js
Normal file
337
js/spatial-audio-chat.js
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
248
tests/test_spatial_audio_chat.js
Normal file
248
tests/test_spatial_audio_chat.js
Normal 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!');
|
||||
Reference in New Issue
Block a user