@newgameplusinc/odyssey-audio-video-sdk-dev 1.0.60 → 1.0.62
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 +43 -0
- package/dist/SpatialAudioManager.js +252 -36
- package/dist/index.js +12 -4
- package/package.json +1 -1
|
@@ -74,6 +74,12 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
74
74
|
*/
|
|
75
75
|
setupSpatialAudioForParticipant(participantId: string, track: MediaStreamTrack, bypassSpatialization?: boolean): Promise<void>;
|
|
76
76
|
private startMonitoring;
|
|
77
|
+
/**
|
|
78
|
+
* Toggle spatialization for a participant (for huddle/spatial switching)
|
|
79
|
+
* @param participantId The participant to update
|
|
80
|
+
* @param enableSpatialization True for spatial audio, false for non-spatial (huddle)
|
|
81
|
+
*/
|
|
82
|
+
setParticipantSpatialization(participantId: string, enableSpatialization: boolean): void;
|
|
77
83
|
/**
|
|
78
84
|
* Update spatial audio position and orientation for a participant
|
|
79
85
|
*
|
|
@@ -131,12 +137,49 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
131
137
|
private normalizePositionUnits;
|
|
132
138
|
private getVectorFromListener;
|
|
133
139
|
private applyDirectionalSuppression;
|
|
140
|
+
/**
|
|
141
|
+
* Dynamically adjust highpass filter based on voice characteristics
|
|
142
|
+
* Analyzes audio spectrum and sets filter between 85-300Hz
|
|
143
|
+
*/
|
|
144
|
+
private adjustVoiceAdaptiveFilter;
|
|
134
145
|
private calculateClarityScore;
|
|
135
146
|
private calculateProximityWeight;
|
|
136
147
|
private calculateDirectionFocus;
|
|
137
148
|
private normalizeVector;
|
|
138
149
|
private clamp;
|
|
139
150
|
private isDenoiserEnabled;
|
|
151
|
+
/**
|
|
152
|
+
* Calculate angle between listener and sound source in degrees (0-360)
|
|
153
|
+
* 0° = front, 90° = right, 180° = back, 270° = left
|
|
154
|
+
*/
|
|
155
|
+
private calculateAngle;
|
|
156
|
+
/**
|
|
157
|
+
* Calculate stereo panning based on angle (0-360°)
|
|
158
|
+
* Returns { left: 0-100, right: 0-100 }
|
|
159
|
+
*
|
|
160
|
+
* Reference angles:
|
|
161
|
+
* 0° (front): L100, R100
|
|
162
|
+
* 45° (front-right): L50, R100
|
|
163
|
+
* 90° (right): L0, R100
|
|
164
|
+
* 135° (back-right): L0, R50
|
|
165
|
+
* 180° (back): L50, R50
|
|
166
|
+
* 225° (back-left): L50, R0
|
|
167
|
+
* 270° (left): L100, R0
|
|
168
|
+
* 315° (front-left): L100, R50
|
|
169
|
+
*/
|
|
170
|
+
private calculatePanning;
|
|
171
|
+
/**
|
|
172
|
+
* Calculate gain based on distance using logarithmic scale
|
|
173
|
+
* Distance range: 0.5m to 5m
|
|
174
|
+
* Gain range: 100% to 0%
|
|
175
|
+
* Uses quadratic equation for human ear perception
|
|
176
|
+
*/
|
|
177
|
+
private calculateLogarithmicGain;
|
|
178
|
+
/**
|
|
179
|
+
* Apply stereo panning to participant audio
|
|
180
|
+
* Converts panning percentages to StereoPanner values
|
|
181
|
+
*/
|
|
182
|
+
private applyStereoPanning;
|
|
140
183
|
private ensureDenoiseWorklet;
|
|
141
184
|
private resolveOptions;
|
|
142
185
|
}
|
|
@@ -142,17 +142,31 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
142
142
|
}
|
|
143
143
|
// Create BiquadFilter nodes for static/noise reduction
|
|
144
144
|
// Based on: https://tagdiwalaviral.medium.com/struggles-of-noise-reduction-in-rtc-part-2-2526f8179442
|
|
145
|
+
// HIGHPASS FILTER: Remove low-frequency rumble (< 80Hz)
|
|
146
|
+
// Human voice fundamental: 80-300Hz, harmonics: 300Hz-8kHz
|
|
147
|
+
// This cuts BELOW voice range while preserving full voice spectrum
|
|
145
148
|
const highpassFilter = this.audioContext.createBiquadFilter();
|
|
146
149
|
highpassFilter.type = "highpass";
|
|
147
|
-
highpassFilter.frequency.value =
|
|
150
|
+
highpassFilter.frequency.value = 80; // Cut frequencies below 80Hz (removes rumble/pops)
|
|
148
151
|
highpassFilter.Q.value = 1.0; // Quality factor
|
|
152
|
+
// LOWPASS FILTER: Remove high-frequency hiss (> 8000Hz)
|
|
153
|
+
// Voice harmonics extend to ~8kHz - this preserves full voice richness
|
|
154
|
+
// while removing digital artifacts and hiss ABOVE useful voice range
|
|
149
155
|
const lowpassFilter = this.audioContext.createBiquadFilter();
|
|
150
156
|
lowpassFilter.type = "lowpass";
|
|
151
|
-
lowpassFilter.frequency.value =
|
|
157
|
+
lowpassFilter.frequency.value = 8000; // Cut frequencies above 8kHz (preserves voice harmonics)
|
|
152
158
|
lowpassFilter.Q.value = 1.0; // Quality factor
|
|
159
|
+
// VOICE BAND EMPHASIS: Boost 80-300Hz fundamental range for clarity
|
|
160
|
+
// This emphasizes the base pitch without affecting harmonics
|
|
161
|
+
// Helps reduce the "pop" when someone starts speaking
|
|
162
|
+
const voiceBandFilter = this.audioContext.createBiquadFilter();
|
|
163
|
+
voiceBandFilter.type = "peaking";
|
|
164
|
+
voiceBandFilter.frequency.value = 180; // Center of voice fundamental (80-300Hz)
|
|
165
|
+
voiceBandFilter.Q.value = 1.5; // Moderate width (~100-260Hz affected)
|
|
166
|
+
voiceBandFilter.gain.value = 2; // +2dB boost for clarity
|
|
153
167
|
const dynamicLowpass = this.audioContext.createBiquadFilter();
|
|
154
168
|
dynamicLowpass.type = "lowpass";
|
|
155
|
-
dynamicLowpass.frequency.value =
|
|
169
|
+
dynamicLowpass.frequency.value = 7500; // Fixed for all angles
|
|
156
170
|
dynamicLowpass.Q.value = 0.8;
|
|
157
171
|
proximityGain.gain.value = 1.0;
|
|
158
172
|
// Configure Panner for realistic 3D spatial audio
|
|
@@ -172,8 +186,11 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
172
186
|
currentNode.connect(denoiseNode);
|
|
173
187
|
currentNode = denoiseNode;
|
|
174
188
|
}
|
|
189
|
+
// Audio chain with voice optimization filters
|
|
190
|
+
// Chain: source -> [denoise] -> highpass -> voiceBand -> lowpass -> dynamicLowpass -> proximityGain -> panner -> analyser -> gain -> masterGain
|
|
175
191
|
currentNode.connect(highpassFilter);
|
|
176
|
-
highpassFilter.connect(
|
|
192
|
+
highpassFilter.connect(voiceBandFilter);
|
|
193
|
+
voiceBandFilter.connect(lowpassFilter);
|
|
177
194
|
lowpassFilter.connect(dynamicLowpass);
|
|
178
195
|
dynamicLowpass.connect(proximityGain);
|
|
179
196
|
if (bypassSpatialization) {
|
|
@@ -196,6 +213,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
196
213
|
proximityGain,
|
|
197
214
|
highpassFilter,
|
|
198
215
|
lowpassFilter,
|
|
216
|
+
voiceBandFilter,
|
|
199
217
|
dynamicLowpass,
|
|
200
218
|
denoiseNode,
|
|
201
219
|
stream,
|
|
@@ -225,6 +243,40 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
225
243
|
}, 2000); // Check every 2 seconds
|
|
226
244
|
this.monitoringIntervals.set(participantId, interval);
|
|
227
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Toggle spatialization for a participant (for huddle/spatial switching)
|
|
248
|
+
* @param participantId The participant to update
|
|
249
|
+
* @param enableSpatialization True for spatial audio, false for non-spatial (huddle)
|
|
250
|
+
*/
|
|
251
|
+
setParticipantSpatialization(participantId, enableSpatialization) {
|
|
252
|
+
const nodes = this.participantNodes.get(participantId);
|
|
253
|
+
if (!nodes) {
|
|
254
|
+
console.warn(`[SpatialAudio] No nodes found for participant ${participantId}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Disconnect and reconnect audio chain
|
|
258
|
+
try {
|
|
259
|
+
// Disconnect from current destination
|
|
260
|
+
nodes.proximityGain.disconnect();
|
|
261
|
+
if (enableSpatialization) {
|
|
262
|
+
// Connect through panner for 3D spatial audio
|
|
263
|
+
nodes.proximityGain.connect(nodes.panner);
|
|
264
|
+
nodes.panner.connect(nodes.analyser);
|
|
265
|
+
console.log(`🎯 [SpatialAudio] Enabled spatialization for ${participantId.substring(0, 8)}`);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
// Bypass panner for non-spatial (huddle) audio
|
|
269
|
+
nodes.proximityGain.connect(nodes.analyser);
|
|
270
|
+
console.log(`🔊 [SpatialAudio] Disabled spatialization (huddle mode) for ${participantId.substring(0, 8)}`);
|
|
271
|
+
}
|
|
272
|
+
// Rest of the chain remains the same
|
|
273
|
+
nodes.analyser.connect(nodes.gain);
|
|
274
|
+
nodes.gain.connect(this.masterGainNode);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
console.error(`[SpatialAudio] Error toggling spatialization for ${participantId}:`, error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
228
280
|
/**
|
|
229
281
|
* Update spatial audio position and orientation for a participant
|
|
230
282
|
*
|
|
@@ -248,35 +300,35 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
248
300
|
updateSpatialAudio(participantId, position, direction) {
|
|
249
301
|
const nodes = this.participantNodes.get(participantId);
|
|
250
302
|
if (nodes?.panner) {
|
|
251
|
-
const distanceConfig = this.getDistanceConfig();
|
|
252
303
|
const normalizedPosition = this.normalizePositionUnits(position);
|
|
253
|
-
const targetPosition = this.applySpatialBoostIfNeeded(normalizedPosition);
|
|
254
|
-
// Update position (where the sound is coming from)
|
|
255
|
-
nodes.panner.positionX.setValueAtTime(targetPosition.x, this.audioContext.currentTime);
|
|
256
|
-
nodes.panner.positionY.setValueAtTime(targetPosition.y, this.audioContext.currentTime);
|
|
257
|
-
nodes.panner.positionZ.setValueAtTime(targetPosition.z, this.audioContext.currentTime);
|
|
258
|
-
// Update orientation (where the participant is facing)
|
|
259
|
-
// This makes the audio source directional based on participant's direction
|
|
260
|
-
if (direction) {
|
|
261
|
-
// Normalize direction vector
|
|
262
|
-
const length = Math.sqrt(direction.x * direction.x +
|
|
263
|
-
direction.y * direction.y +
|
|
264
|
-
direction.z * direction.z);
|
|
265
|
-
if (length > 0.001) {
|
|
266
|
-
const normX = direction.x / length;
|
|
267
|
-
const normY = direction.y / length;
|
|
268
|
-
const normZ = direction.z / length;
|
|
269
|
-
nodes.panner.orientationX.setValueAtTime(normX, this.audioContext.currentTime);
|
|
270
|
-
nodes.panner.orientationY.setValueAtTime(normY, this.audioContext.currentTime);
|
|
271
|
-
nodes.panner.orientationZ.setValueAtTime(normZ, this.audioContext.currentTime);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
304
|
const listenerPos = this.listenerPosition;
|
|
275
|
-
|
|
276
|
-
const distance = this.getDistanceBetween(listenerPos,
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
305
|
+
// Calculate distance (in meters)
|
|
306
|
+
const distance = this.getDistanceBetween(listenerPos, normalizedPosition);
|
|
307
|
+
// Calculate angle between listener and source
|
|
308
|
+
const angle = this.calculateAngle(listenerPos, normalizedPosition, this.listenerDirection.forward);
|
|
309
|
+
// Calculate stereo panning based on angle
|
|
310
|
+
const panning = this.calculatePanning(angle);
|
|
311
|
+
// Calculate logarithmic gain based on distance
|
|
312
|
+
const gain = this.calculateLogarithmicGain(distance);
|
|
313
|
+
// Apply panning
|
|
314
|
+
this.applyStereoPanning(participantId, panning);
|
|
315
|
+
// Apply gain with smooth transition to reduce clicking/popping
|
|
316
|
+
const gainValue = gain / 100; // Convert to 0-1 range
|
|
317
|
+
nodes.gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05 // Smooth transition over 50ms to reduce clicking
|
|
318
|
+
);
|
|
319
|
+
// Apply proximity gain for additional distance-based attenuation
|
|
320
|
+
nodes.proximityGain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05);
|
|
321
|
+
// Update 3D position for PannerNode (still used for vertical positioning)
|
|
322
|
+
nodes.panner.positionY.setValueAtTime(normalizedPosition.y, this.audioContext.currentTime);
|
|
323
|
+
nodes.panner.positionZ.setValueAtTime(normalizedPosition.z, this.audioContext.currentTime);
|
|
324
|
+
// Log for debugging (remove in production)
|
|
325
|
+
if (Math.random() < 0.01) { // Log 1% of updates to avoid spam
|
|
326
|
+
console.log(`[Spatial Audio] Participant: ${participantId}`);
|
|
327
|
+
console.log(` Distance: ${distance.toFixed(2)}m`);
|
|
328
|
+
console.log(` Angle: ${angle.toFixed(1)}°`);
|
|
329
|
+
console.log(` Panning: L${panning.left.toFixed(0)}% R${panning.right.toFixed(0)}%`);
|
|
330
|
+
console.log(` Gain: ${gain.toFixed(0)}%`);
|
|
331
|
+
}
|
|
280
332
|
}
|
|
281
333
|
}
|
|
282
334
|
/**
|
|
@@ -322,7 +374,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
322
374
|
const fwdY = forwardY / forwardLen;
|
|
323
375
|
const fwdZ = forwardZ / forwardLen;
|
|
324
376
|
// Calculate right vector (cross product of world up and forward)
|
|
325
|
-
|
|
377
|
+
// Web Audio API uses Y-up coordinate system, Unreal uses Z-up
|
|
378
|
+
// We need to transform: Unreal (X,Y,Z) -> WebAudio (X,Z,-Y)
|
|
379
|
+
const worldUp = { x: 0, y: 1, z: 0 }; // Web Audio Y-up
|
|
326
380
|
const rightX = worldUp.y * fwdZ - worldUp.z * fwdY;
|
|
327
381
|
const rightY = worldUp.z * fwdX - worldUp.x * fwdZ;
|
|
328
382
|
const rightZ = worldUp.x * fwdY - worldUp.y * fwdX;
|
|
@@ -334,8 +388,8 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
334
388
|
forwardY: fwdY,
|
|
335
389
|
forwardZ: fwdZ,
|
|
336
390
|
upX: 0,
|
|
337
|
-
upY:
|
|
338
|
-
upZ:
|
|
391
|
+
upY: 1,
|
|
392
|
+
upZ: 0,
|
|
339
393
|
});
|
|
340
394
|
return;
|
|
341
395
|
}
|
|
@@ -499,9 +553,45 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
499
553
|
}
|
|
500
554
|
const clarityScore = this.calculateClarityScore(distance, vectorToSource);
|
|
501
555
|
const targetGain = 0.48 + clarityScore * 0.72; // 0.48 → 1.20
|
|
502
|
-
|
|
556
|
+
// Only adjust gain based on angle, not frequency
|
|
503
557
|
nodes.proximityGain.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, 0.08);
|
|
504
|
-
|
|
558
|
+
// Analyze voice and adjust highpass filter dynamically (85-300Hz)
|
|
559
|
+
this.adjustVoiceAdaptiveFilter(participantId);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Dynamically adjust highpass filter based on voice characteristics
|
|
563
|
+
* Analyzes audio spectrum and sets filter between 85-300Hz
|
|
564
|
+
*/
|
|
565
|
+
adjustVoiceAdaptiveFilter(participantId) {
|
|
566
|
+
const nodes = this.participantNodes.get(participantId);
|
|
567
|
+
if (!nodes?.analyser) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const bufferLength = nodes.analyser.frequencyBinCount;
|
|
571
|
+
const dataArray = new Uint8Array(bufferLength);
|
|
572
|
+
nodes.analyser.getByteFrequencyData(dataArray);
|
|
573
|
+
// Calculate spectral centroid in low frequency range (0-500Hz)
|
|
574
|
+
const sampleRate = this.audioContext.sampleRate;
|
|
575
|
+
const nyquist = sampleRate / 2;
|
|
576
|
+
const binWidth = nyquist / bufferLength;
|
|
577
|
+
let weightedSum = 0;
|
|
578
|
+
let totalEnergy = 0;
|
|
579
|
+
const maxBin = Math.floor(500 / binWidth); // Only analyze up to 500Hz
|
|
580
|
+
for (let i = 0; i < Math.min(maxBin, bufferLength); i++) {
|
|
581
|
+
const frequency = i * binWidth;
|
|
582
|
+
const magnitude = dataArray[i] / 255.0;
|
|
583
|
+
weightedSum += frequency * magnitude;
|
|
584
|
+
totalEnergy += magnitude;
|
|
585
|
+
}
|
|
586
|
+
if (totalEnergy > 0.01) {
|
|
587
|
+
const centroid = weightedSum / totalEnergy;
|
|
588
|
+
// Map centroid to highpass frequency (85-300Hz)
|
|
589
|
+
// Lower centroid = deeper voice = use lower highpass (preserve bass)
|
|
590
|
+
// Higher centroid = higher voice = use higher highpass (remove mud)
|
|
591
|
+
const targetFreq = Math.max(85, Math.min(300, 85 + (centroid - 100) * 0.5));
|
|
592
|
+
nodes.highpassFilter.frequency.setTargetAtTime(targetFreq, this.audioContext.currentTime, 0.15 // Smooth transition
|
|
593
|
+
);
|
|
594
|
+
}
|
|
505
595
|
}
|
|
506
596
|
calculateClarityScore(distance, vectorToSource) {
|
|
507
597
|
const proximityWeight = this.calculateProximityWeight(distance);
|
|
@@ -545,6 +635,132 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
545
635
|
isDenoiserEnabled() {
|
|
546
636
|
return this.options.denoiser?.enabled !== false;
|
|
547
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* Calculate angle between listener and sound source in degrees (0-360)
|
|
640
|
+
* 0° = front, 90° = right, 180° = back, 270° = left
|
|
641
|
+
*/
|
|
642
|
+
calculateAngle(listenerPos, sourcePos, listenerForward) {
|
|
643
|
+
// Vector from listener to source
|
|
644
|
+
const dx = sourcePos.x - listenerPos.x;
|
|
645
|
+
const dy = sourcePos.y - listenerPos.y;
|
|
646
|
+
// Project onto horizontal plane (assuming Z is up)
|
|
647
|
+
// Use listener's forward direction to determine angle
|
|
648
|
+
const forwardX = listenerForward.x;
|
|
649
|
+
const forwardY = listenerForward.y;
|
|
650
|
+
// Calculate angle using atan2
|
|
651
|
+
const angleToSource = Math.atan2(dy, dx);
|
|
652
|
+
const forwardAngle = Math.atan2(forwardY, forwardX);
|
|
653
|
+
// Relative angle in radians
|
|
654
|
+
let relativeAngle = angleToSource - forwardAngle;
|
|
655
|
+
// Normalize to 0-2π
|
|
656
|
+
while (relativeAngle < 0)
|
|
657
|
+
relativeAngle += Math.PI * 2;
|
|
658
|
+
while (relativeAngle >= Math.PI * 2)
|
|
659
|
+
relativeAngle -= Math.PI * 2;
|
|
660
|
+
// Convert to degrees (0-360)
|
|
661
|
+
return (relativeAngle * 180 / Math.PI);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Calculate stereo panning based on angle (0-360°)
|
|
665
|
+
* Returns { left: 0-100, right: 0-100 }
|
|
666
|
+
*
|
|
667
|
+
* Reference angles:
|
|
668
|
+
* 0° (front): L100, R100
|
|
669
|
+
* 45° (front-right): L50, R100
|
|
670
|
+
* 90° (right): L0, R100
|
|
671
|
+
* 135° (back-right): L0, R50
|
|
672
|
+
* 180° (back): L50, R50
|
|
673
|
+
* 225° (back-left): L50, R0
|
|
674
|
+
* 270° (left): L100, R0
|
|
675
|
+
* 315° (front-left): L100, R50
|
|
676
|
+
*/
|
|
677
|
+
calculatePanning(angle) {
|
|
678
|
+
// Normalize angle to 0-360
|
|
679
|
+
while (angle < 0)
|
|
680
|
+
angle += 360;
|
|
681
|
+
while (angle >= 360)
|
|
682
|
+
angle -= 360;
|
|
683
|
+
let left = 100;
|
|
684
|
+
let right = 100;
|
|
685
|
+
if (angle <= 90) {
|
|
686
|
+
// Front-right quadrant (0° to 90°)
|
|
687
|
+
// Left decreases from 100 to 0
|
|
688
|
+
// Right stays at 100
|
|
689
|
+
left = 100 * (1 - angle / 90);
|
|
690
|
+
right = 100;
|
|
691
|
+
}
|
|
692
|
+
else if (angle <= 180) {
|
|
693
|
+
// Back-right quadrant (90° to 180°)
|
|
694
|
+
// Left stays at 0
|
|
695
|
+
// Right decreases from 100 to 50
|
|
696
|
+
left = 0;
|
|
697
|
+
right = 100 - 50 * ((angle - 90) / 90);
|
|
698
|
+
}
|
|
699
|
+
else if (angle <= 270) {
|
|
700
|
+
// Back-left quadrant (180° to 270°)
|
|
701
|
+
// Left increases from 0 to 100
|
|
702
|
+
// Right decreases from 50 to 0
|
|
703
|
+
const progress = (angle - 180) / 90;
|
|
704
|
+
left = 50 + 50 * progress;
|
|
705
|
+
right = 50 * (1 - progress);
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
// Front-left quadrant (270° to 360°)
|
|
709
|
+
// Left stays at 100
|
|
710
|
+
// Right increases from 0 to 100
|
|
711
|
+
left = 100;
|
|
712
|
+
right = 100 * ((angle - 270) / 90);
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
left: Math.max(0, Math.min(100, left)),
|
|
716
|
+
right: Math.max(0, Math.min(100, right))
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Calculate gain based on distance using logarithmic scale
|
|
721
|
+
* Distance range: 0.5m to 5m
|
|
722
|
+
* Gain range: 100% to 0%
|
|
723
|
+
* Uses quadratic equation for human ear perception
|
|
724
|
+
*/
|
|
725
|
+
calculateLogarithmicGain(distance) {
|
|
726
|
+
const minDistance = 0.5; // meters
|
|
727
|
+
const maxDistance = 5.0; // meters
|
|
728
|
+
// Clamp distance
|
|
729
|
+
if (distance <= minDistance)
|
|
730
|
+
return 100;
|
|
731
|
+
if (distance >= maxDistance)
|
|
732
|
+
return 0;
|
|
733
|
+
// Normalize distance to 0-1 range
|
|
734
|
+
const normalizedDistance = (distance - minDistance) / (maxDistance - minDistance);
|
|
735
|
+
// Apply quadratic falloff for natural perception
|
|
736
|
+
// gain = 100 * (1 - x²)
|
|
737
|
+
// This creates a logarithmic-like curve that sounds linear to human ear
|
|
738
|
+
const gain = 100 * Math.pow(1 - normalizedDistance, 2);
|
|
739
|
+
return Math.max(0, Math.min(100, gain));
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Apply stereo panning to participant audio
|
|
743
|
+
* Converts panning percentages to StereoPanner values
|
|
744
|
+
*/
|
|
745
|
+
applyStereoPanning(participantId, panning) {
|
|
746
|
+
const nodes = this.participantNodes.get(participantId);
|
|
747
|
+
if (!nodes?.panner)
|
|
748
|
+
return;
|
|
749
|
+
// Convert left/right percentages to pan value (-1 to +1)
|
|
750
|
+
// If left=100, right=0 → pan = -1 (full left)
|
|
751
|
+
// If left=0, right=100 → pan = +1 (full right)
|
|
752
|
+
// If left=100, right=100 → pan = 0 (center)
|
|
753
|
+
const leftRatio = panning.left / 100;
|
|
754
|
+
const rightRatio = panning.right / 100;
|
|
755
|
+
// Calculate pan position
|
|
756
|
+
let panValue = 0;
|
|
757
|
+
if (leftRatio + rightRatio > 0) {
|
|
758
|
+
panValue = (rightRatio - leftRatio);
|
|
759
|
+
}
|
|
760
|
+
// Adjust X position for left-right panning (-1 = left, +1 = right)
|
|
761
|
+
const currentTime = this.audioContext.currentTime;
|
|
762
|
+
nodes.panner.positionX.setTargetAtTime(panValue * 5, currentTime, 0.05);
|
|
763
|
+
}
|
|
548
764
|
async ensureDenoiseWorklet() {
|
|
549
765
|
if (!this.isDenoiserEnabled()) {
|
|
550
766
|
return;
|
package/dist/index.js
CHANGED
|
@@ -383,11 +383,16 @@ class OdysseySpatialComms extends EventManager_1.EventManager {
|
|
|
383
383
|
return; // Exit early to prevent any audio processing
|
|
384
384
|
}
|
|
385
385
|
else {
|
|
386
|
-
//
|
|
387
|
-
|
|
386
|
+
// Check if participant is in a huddle (non-spatial channel)
|
|
387
|
+
const participantChannel = participant.currentChannel || "spatial";
|
|
388
|
+
const isInHuddle = participantChannel !== "spatial";
|
|
389
|
+
// Setup spatial audio - bypass 3D positioning for huddle members
|
|
390
|
+
await this.spatialAudioManager.setupSpatialAudioForParticipant(participant.participantId, track, isInHuddle // Bypass spatialization if in huddle
|
|
388
391
|
);
|
|
389
|
-
//
|
|
390
|
-
|
|
392
|
+
// Only update spatial position if in spatial channel
|
|
393
|
+
if (!isInHuddle) {
|
|
394
|
+
this.spatialAudioManager.updateSpatialAudio(participant.participantId, data.position);
|
|
395
|
+
}
|
|
391
396
|
}
|
|
392
397
|
// NOW resume the consumer after audio pipeline is ready
|
|
393
398
|
this.mediasoupManager
|
|
@@ -496,6 +501,9 @@ class OdysseySpatialComms extends EventManager_1.EventManager {
|
|
|
496
501
|
const participant = this.room?.participants.get(data.participantId);
|
|
497
502
|
if (participant) {
|
|
498
503
|
participant.currentChannel = data.channelId;
|
|
504
|
+
// Update spatialization based on channel
|
|
505
|
+
const isInSpatialChannel = data.channelId === "spatial";
|
|
506
|
+
this.spatialAudioManager.setParticipantSpatialization(data.participantId, isInSpatialChannel);
|
|
499
507
|
// If this participant is now in a different channel from us, clear their screenshare
|
|
500
508
|
const myChannel = this.localParticipant?.currentChannel || "spatial";
|
|
501
509
|
const theirChannel = data.channelId || "spatial";
|
package/package.json
CHANGED