@newgameplusinc/odyssey-audio-video-sdk-dev 1.0.3 โ 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SpatialAudioManager.d.ts +48 -1
- package/dist/SpatialAudioManager.js +183 -28
- package/dist/index.js +3 -2
- package/package.json +1 -1
|
@@ -5,11 +5,52 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
5
5
|
private participantNodes;
|
|
6
6
|
private masterGainNode;
|
|
7
7
|
private monitoringIntervals;
|
|
8
|
+
private compressor;
|
|
9
|
+
private listenerDirection;
|
|
8
10
|
constructor();
|
|
9
11
|
getAudioContext(): AudioContext;
|
|
12
|
+
/**
|
|
13
|
+
* Setup spatial audio for a participant
|
|
14
|
+
*
|
|
15
|
+
* CRITICAL: Each participant gets their OWN audio processing chain:
|
|
16
|
+
* Stream -> Source -> Panner -> Analyser -> Gain -> Compressor -> Output
|
|
17
|
+
*
|
|
18
|
+
* This ensures:
|
|
19
|
+
* - Each voice is positioned independently in 3D space
|
|
20
|
+
* - No server-side mixing required
|
|
21
|
+
* - Scalable to unlimited participants (browser handles the mixing)
|
|
22
|
+
*
|
|
23
|
+
* @param participantId Unique ID for this participant
|
|
24
|
+
* @param track Audio track from MediaSoup consumer
|
|
25
|
+
* @param bypassSpatialization For testing - bypasses 3D positioning
|
|
26
|
+
*/
|
|
10
27
|
setupSpatialAudioForParticipant(participantId: string, track: MediaStreamTrack, bypassSpatialization?: boolean): void;
|
|
11
28
|
private startMonitoring;
|
|
12
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Update spatial audio position and orientation for a participant
|
|
31
|
+
*
|
|
32
|
+
* This is called every time we receive position/direction updates from the server.
|
|
33
|
+
*
|
|
34
|
+
* Position: Where the participant is in 3D space (their location)
|
|
35
|
+
* Direction: Which way they're facing (their forward vector)
|
|
36
|
+
*
|
|
37
|
+
* Example:
|
|
38
|
+
* - Position: (x: -200, y: 0, z: 100) = 2m to your left
|
|
39
|
+
* - Direction: (x: 0, y: 1, z: 0) = facing forward (away from you)
|
|
40
|
+
* - Result: Sound comes from your left, oriented as if speaking away
|
|
41
|
+
*
|
|
42
|
+
* The Web Audio API's PannerNode uses HRTF to create realistic 3D audio
|
|
43
|
+
* based on these parameters plus the listener's position/orientation.
|
|
44
|
+
*
|
|
45
|
+
* @param participantId Who to update
|
|
46
|
+
* @param position Where they are (from socket data)
|
|
47
|
+
* @param direction Which way they're facing (from socket data)
|
|
48
|
+
*/
|
|
49
|
+
updateSpatialAudio(participantId: string, position: Position, direction?: {
|
|
50
|
+
x: number;
|
|
51
|
+
y: number;
|
|
52
|
+
z: number;
|
|
53
|
+
}): void;
|
|
13
54
|
setListenerPosition(position: Position, orientation: {
|
|
14
55
|
forwardX: number;
|
|
15
56
|
forwardY: number;
|
|
@@ -18,6 +59,12 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
18
59
|
upY: number;
|
|
19
60
|
upZ: number;
|
|
20
61
|
}): void;
|
|
62
|
+
/**
|
|
63
|
+
* Update listener orientation from LSD camera direction
|
|
64
|
+
* @param cameraPos Camera position in world space
|
|
65
|
+
* @param lookAtPos Look-at position (where camera is pointing)
|
|
66
|
+
*/
|
|
67
|
+
setListenerFromLSD(listenerPos: Position, cameraPos: Position, lookAtPos: Position): void;
|
|
21
68
|
removeParticipant(participantId: string): void;
|
|
22
69
|
resumeAudioContext(): Promise<void>;
|
|
23
70
|
getAudioContextState(): AudioContextState;
|
|
@@ -7,54 +7,82 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
7
7
|
super();
|
|
8
8
|
this.participantNodes = new Map();
|
|
9
9
|
this.monitoringIntervals = new Map();
|
|
10
|
-
this.
|
|
10
|
+
this.listenerDirection = {
|
|
11
|
+
forward: { x: 0, y: 1, z: 0 },
|
|
12
|
+
up: { x: 0, y: 0, z: 1 },
|
|
13
|
+
};
|
|
14
|
+
// Use high sample rate for best audio quality
|
|
15
|
+
this.audioContext = new AudioContext({ sampleRate: 48000 });
|
|
16
|
+
// Master gain
|
|
11
17
|
this.masterGainNode = this.audioContext.createGain();
|
|
12
|
-
this.masterGainNode.gain.value = 5.0;
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
this.masterGainNode.gain.value = 5.0;
|
|
19
|
+
// Compressor for dynamic range control and preventing distortion
|
|
20
|
+
this.compressor = this.audioContext.createDynamicsCompressor();
|
|
21
|
+
this.compressor.threshold.value = -24; // dB
|
|
22
|
+
this.compressor.knee.value = 30; // dB
|
|
23
|
+
this.compressor.ratio.value = 12; // Compression ratio
|
|
24
|
+
this.compressor.attack.value = 0.003; // 3ms attack
|
|
25
|
+
this.compressor.release.value = 0.25; // 250ms release
|
|
26
|
+
// Connect master chain: masterGain -> compressor -> destination
|
|
27
|
+
this.masterGainNode.connect(this.compressor);
|
|
28
|
+
this.compressor.connect(this.audioContext.destination);
|
|
29
|
+
console.log(`๐ SpatialAudioManager initialized with advanced audio processing:`, {
|
|
30
|
+
sampleRate: this.audioContext.sampleRate,
|
|
31
|
+
gain: this.masterGainNode.gain.value,
|
|
32
|
+
compressor: {
|
|
33
|
+
threshold: this.compressor.threshold.value,
|
|
34
|
+
ratio: this.compressor.ratio.value,
|
|
35
|
+
},
|
|
36
|
+
state: this.audioContext.state,
|
|
37
|
+
});
|
|
15
38
|
}
|
|
16
39
|
getAudioContext() {
|
|
17
40
|
return this.audioContext;
|
|
18
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Setup spatial audio for a participant
|
|
44
|
+
*
|
|
45
|
+
* CRITICAL: Each participant gets their OWN audio processing chain:
|
|
46
|
+
* Stream -> Source -> Panner -> Analyser -> Gain -> Compressor -> Output
|
|
47
|
+
*
|
|
48
|
+
* This ensures:
|
|
49
|
+
* - Each voice is positioned independently in 3D space
|
|
50
|
+
* - No server-side mixing required
|
|
51
|
+
* - Scalable to unlimited participants (browser handles the mixing)
|
|
52
|
+
*
|
|
53
|
+
* @param participantId Unique ID for this participant
|
|
54
|
+
* @param track Audio track from MediaSoup consumer
|
|
55
|
+
* @param bypassSpatialization For testing - bypasses 3D positioning
|
|
56
|
+
*/
|
|
19
57
|
setupSpatialAudioForParticipant(participantId, track, bypassSpatialization = false // Default to false
|
|
20
58
|
) {
|
|
21
59
|
if (this.audioContext.state === "suspended") {
|
|
22
60
|
this.audioContext.resume();
|
|
23
61
|
}
|
|
62
|
+
// Create stream with noise suppression constraints
|
|
24
63
|
const stream = new MediaStream([track]);
|
|
25
|
-
// ๐ DIAGNOSTIC TEST: Try playing through raw HTML audio element
|
|
26
|
-
console.warn(`๐งช DIAGNOSTIC: Testing raw audio playback for ${participantId}`);
|
|
27
|
-
const testAudio = new Audio();
|
|
28
|
-
testAudio.srcObject = stream;
|
|
29
|
-
testAudio.volume = 1.0;
|
|
30
|
-
testAudio
|
|
31
|
-
.play()
|
|
32
|
-
.then(() => {
|
|
33
|
-
console.log(`โ
DIAGNOSTIC: Raw audio element can play track for ${participantId}`);
|
|
34
|
-
})
|
|
35
|
-
.catch((err) => {
|
|
36
|
-
console.error(`โ DIAGNOSTIC: Raw audio element CANNOT play:`, err);
|
|
37
|
-
});
|
|
38
64
|
const source = this.audioContext.createMediaStreamSource(stream);
|
|
39
65
|
const panner = this.audioContext.createPanner();
|
|
40
66
|
const analyser = this.audioContext.createAnalyser();
|
|
41
67
|
const gain = this.audioContext.createGain();
|
|
42
|
-
// Configure Panner
|
|
43
|
-
panner.panningModel = "HRTF";
|
|
44
|
-
panner.distanceModel = "inverse";
|
|
45
|
-
panner.refDistance = 1
|
|
46
|
-
panner.maxDistance =
|
|
47
|
-
panner.rolloffFactor = 1;
|
|
48
|
-
panner.coneInnerAngle = 360;
|
|
49
|
-
panner.coneOuterAngle =
|
|
50
|
-
panner.coneOuterGain = 0;
|
|
68
|
+
// Configure Panner for realistic 3D spatial audio
|
|
69
|
+
panner.panningModel = "HRTF"; // Head-Related Transfer Function for realistic 3D
|
|
70
|
+
panner.distanceModel = "inverse"; // Natural distance falloff
|
|
71
|
+
panner.refDistance = 100; // Distance at which volume = 1.0 (1 meter in Unreal = 100cm)
|
|
72
|
+
panner.maxDistance = 1500; // Maximum audible distance (15 meters)
|
|
73
|
+
panner.rolloffFactor = 1.5; // How quickly sound fades with distance
|
|
74
|
+
panner.coneInnerAngle = 360; // Omnidirectional sound source
|
|
75
|
+
panner.coneOuterAngle = 360;
|
|
76
|
+
panner.coneOuterGain = 0.3; // Some sound even outside cone
|
|
77
|
+
// Configure gain for individual participant volume control
|
|
78
|
+
gain.gain.value = 1.0;
|
|
51
79
|
if (bypassSpatialization) {
|
|
52
80
|
console.log(`๐ TESTING: Connecting audio directly to destination (bypassing spatial audio) for ${participantId}`);
|
|
53
81
|
source.connect(analyser);
|
|
54
82
|
analyser.connect(this.masterGainNode);
|
|
55
83
|
}
|
|
56
84
|
else {
|
|
57
|
-
// Standard spatialized path
|
|
85
|
+
// Standard spatialized path with full audio chain
|
|
58
86
|
source.connect(panner);
|
|
59
87
|
panner.connect(analyser);
|
|
60
88
|
analyser.connect(gain);
|
|
@@ -69,11 +97,19 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
69
97
|
});
|
|
70
98
|
console.log(`๐ง Spatial audio setup complete for ${participantId}:`, {
|
|
71
99
|
audioContextState: this.audioContext.state,
|
|
100
|
+
sampleRate: this.audioContext.sampleRate,
|
|
72
101
|
gain: this.masterGainNode.gain.value,
|
|
73
102
|
trackEnabled: track.enabled,
|
|
74
103
|
trackMuted: track.muted,
|
|
75
104
|
trackReadyState: track.readyState,
|
|
76
105
|
isBypassed: bypassSpatialization,
|
|
106
|
+
pannerConfig: {
|
|
107
|
+
model: panner.panningModel,
|
|
108
|
+
distanceModel: panner.distanceModel,
|
|
109
|
+
refDistance: panner.refDistance,
|
|
110
|
+
maxDistance: panner.maxDistance,
|
|
111
|
+
rolloffFactor: panner.rolloffFactor,
|
|
112
|
+
},
|
|
77
113
|
});
|
|
78
114
|
// Start monitoring audio levels
|
|
79
115
|
this.startMonitoring(participantId);
|
|
@@ -121,17 +157,67 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
121
157
|
}, 2000); // Log every 2 seconds
|
|
122
158
|
this.monitoringIntervals.set(participantId, interval);
|
|
123
159
|
}
|
|
124
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Update spatial audio position and orientation for a participant
|
|
162
|
+
*
|
|
163
|
+
* This is called every time we receive position/direction updates from the server.
|
|
164
|
+
*
|
|
165
|
+
* Position: Where the participant is in 3D space (their location)
|
|
166
|
+
* Direction: Which way they're facing (their forward vector)
|
|
167
|
+
*
|
|
168
|
+
* Example:
|
|
169
|
+
* - Position: (x: -200, y: 0, z: 100) = 2m to your left
|
|
170
|
+
* - Direction: (x: 0, y: 1, z: 0) = facing forward (away from you)
|
|
171
|
+
* - Result: Sound comes from your left, oriented as if speaking away
|
|
172
|
+
*
|
|
173
|
+
* The Web Audio API's PannerNode uses HRTF to create realistic 3D audio
|
|
174
|
+
* based on these parameters plus the listener's position/orientation.
|
|
175
|
+
*
|
|
176
|
+
* @param participantId Who to update
|
|
177
|
+
* @param position Where they are (from socket data)
|
|
178
|
+
* @param direction Which way they're facing (from socket data)
|
|
179
|
+
*/
|
|
180
|
+
updateSpatialAudio(participantId, position, direction) {
|
|
125
181
|
const nodes = this.participantNodes.get(participantId);
|
|
126
182
|
if (nodes?.panner) {
|
|
183
|
+
// Update position (where the sound is coming from)
|
|
127
184
|
nodes.panner.positionX.setValueAtTime(position.x, this.audioContext.currentTime);
|
|
128
185
|
nodes.panner.positionY.setValueAtTime(position.y, this.audioContext.currentTime);
|
|
129
186
|
nodes.panner.positionZ.setValueAtTime(position.z, this.audioContext.currentTime);
|
|
187
|
+
// Update orientation (where the participant is facing)
|
|
188
|
+
// This makes the audio source directional based on participant's direction
|
|
189
|
+
if (direction) {
|
|
190
|
+
// Normalize direction vector
|
|
191
|
+
const length = Math.sqrt(direction.x * direction.x +
|
|
192
|
+
direction.y * direction.y +
|
|
193
|
+
direction.z * direction.z);
|
|
194
|
+
if (length > 0.001) {
|
|
195
|
+
const normX = direction.x / length;
|
|
196
|
+
const normY = direction.y / length;
|
|
197
|
+
const normZ = direction.z / length;
|
|
198
|
+
nodes.panner.orientationX.setValueAtTime(normX, this.audioContext.currentTime);
|
|
199
|
+
nodes.panner.orientationY.setValueAtTime(normY, this.audioContext.currentTime);
|
|
200
|
+
nodes.panner.orientationZ.setValueAtTime(normZ, this.audioContext.currentTime);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
130
203
|
}
|
|
131
204
|
}
|
|
132
205
|
setListenerPosition(position, orientation) {
|
|
133
206
|
const { listener } = this.audioContext;
|
|
134
207
|
if (listener) {
|
|
208
|
+
// Store listener direction for reference
|
|
209
|
+
this.listenerDirection = {
|
|
210
|
+
forward: {
|
|
211
|
+
x: orientation.forwardX,
|
|
212
|
+
y: orientation.forwardY,
|
|
213
|
+
z: orientation.forwardZ,
|
|
214
|
+
},
|
|
215
|
+
up: {
|
|
216
|
+
x: orientation.upX,
|
|
217
|
+
y: orientation.upY,
|
|
218
|
+
z: orientation.upZ,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
135
221
|
// Use setPosition and setOrientation for atomic updates if available
|
|
136
222
|
if (listener.positionX) {
|
|
137
223
|
listener.positionX.setValueAtTime(position.x, this.audioContext.currentTime);
|
|
@@ -146,8 +232,77 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
146
232
|
listener.upY.setValueAtTime(orientation.upY, this.audioContext.currentTime);
|
|
147
233
|
listener.upZ.setValueAtTime(orientation.upZ, this.audioContext.currentTime);
|
|
148
234
|
}
|
|
235
|
+
// Log spatial audio updates occasionally
|
|
236
|
+
if (Math.random() < 0.01) {
|
|
237
|
+
console.log(`๐ง [Spatial Audio] Listener updated:`, {
|
|
238
|
+
position: { x: position.x.toFixed(1), y: position.y.toFixed(1), z: position.z.toFixed(1) },
|
|
239
|
+
forward: {
|
|
240
|
+
x: orientation.forwardX.toFixed(2),
|
|
241
|
+
y: orientation.forwardY.toFixed(2),
|
|
242
|
+
z: orientation.forwardZ.toFixed(2),
|
|
243
|
+
},
|
|
244
|
+
up: {
|
|
245
|
+
x: orientation.upX.toFixed(2),
|
|
246
|
+
y: orientation.upY.toFixed(2),
|
|
247
|
+
z: orientation.upZ.toFixed(2),
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
149
251
|
}
|
|
150
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Update listener orientation from LSD camera direction
|
|
255
|
+
* @param cameraPos Camera position in world space
|
|
256
|
+
* @param lookAtPos Look-at position (where camera is pointing)
|
|
257
|
+
*/
|
|
258
|
+
setListenerFromLSD(listenerPos, cameraPos, lookAtPos) {
|
|
259
|
+
// Calculate forward vector (from camera to look-at point)
|
|
260
|
+
const forwardX = lookAtPos.x - cameraPos.x;
|
|
261
|
+
const forwardY = lookAtPos.y - cameraPos.y;
|
|
262
|
+
const forwardZ = lookAtPos.z - cameraPos.z;
|
|
263
|
+
// Normalize forward vector
|
|
264
|
+
const forwardLen = Math.sqrt(forwardX * forwardX + forwardY * forwardY + forwardZ * forwardZ);
|
|
265
|
+
if (forwardLen < 0.001) {
|
|
266
|
+
console.warn("โ ๏ธ Forward vector too small, using default orientation");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const fwdX = forwardX / forwardLen;
|
|
270
|
+
const fwdY = forwardY / forwardLen;
|
|
271
|
+
const fwdZ = forwardZ / forwardLen;
|
|
272
|
+
// Calculate right vector (cross product of world up and forward)
|
|
273
|
+
const worldUp = { x: 0, y: 0, z: 1 }; // Unreal Z-up
|
|
274
|
+
const rightX = worldUp.y * fwdZ - worldUp.z * fwdY;
|
|
275
|
+
const rightY = worldUp.z * fwdX - worldUp.x * fwdZ;
|
|
276
|
+
const rightZ = worldUp.x * fwdY - worldUp.y * fwdX;
|
|
277
|
+
const rightLen = Math.sqrt(rightX * rightX + rightY * rightY + rightZ * rightZ);
|
|
278
|
+
if (rightLen < 0.001) {
|
|
279
|
+
// Forward is parallel to world up, use fallback
|
|
280
|
+
this.setListenerPosition(listenerPos, {
|
|
281
|
+
forwardX: fwdX,
|
|
282
|
+
forwardY: fwdY,
|
|
283
|
+
forwardZ: fwdZ,
|
|
284
|
+
upX: 0,
|
|
285
|
+
upY: 0,
|
|
286
|
+
upZ: 1,
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const rX = rightX / rightLen;
|
|
291
|
+
const rY = rightY / rightLen;
|
|
292
|
+
const rZ = rightZ / rightLen;
|
|
293
|
+
// Calculate true up vector (cross product of forward and right)
|
|
294
|
+
const upX = fwdY * rZ - fwdZ * rY;
|
|
295
|
+
const upY = fwdZ * rX - fwdX * rZ;
|
|
296
|
+
const upZ = fwdX * rY - fwdY * rX;
|
|
297
|
+
this.setListenerPosition(listenerPos, {
|
|
298
|
+
forwardX: fwdX,
|
|
299
|
+
forwardY: fwdY,
|
|
300
|
+
forwardZ: fwdZ,
|
|
301
|
+
upX,
|
|
302
|
+
upY,
|
|
303
|
+
upZ,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
151
306
|
removeParticipant(participantId) {
|
|
152
307
|
// Stop monitoring
|
|
153
308
|
if (this.monitoringIntervals.has(participantId)) {
|
package/dist/index.js
CHANGED
|
@@ -457,8 +457,9 @@ class OdysseySpatialComms extends EventManager_1.EventManager {
|
|
|
457
457
|
participant.userName = data.userName;
|
|
458
458
|
if (data.userEmail !== undefined)
|
|
459
459
|
participant.userEmail = data.userEmail;
|
|
460
|
-
|
|
461
|
-
|
|
460
|
+
// Update spatial audio with BOTH position AND direction from socket
|
|
461
|
+
this.spatialAudioManager.updateSpatialAudio(data.participantId, data.position, data.direction);
|
|
462
|
+
console.log("โ
SDK: Updated participant position and spatial audio with direction");
|
|
462
463
|
this.emit("participant-position-updated", participant);
|
|
463
464
|
}
|
|
464
465
|
else {
|
package/package.json
CHANGED