@newgameplusinc/odyssey-audio-video-sdk-dev 1.0.61 → 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 +37 -0
- package/dist/SpatialAudioManager.js +213 -33
- package/package.json +1 -1
|
@@ -137,12 +137,49 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
137
137
|
private normalizePositionUnits;
|
|
138
138
|
private getVectorFromListener;
|
|
139
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;
|
|
140
145
|
private calculateClarityScore;
|
|
141
146
|
private calculateProximityWeight;
|
|
142
147
|
private calculateDirectionFocus;
|
|
143
148
|
private normalizeVector;
|
|
144
149
|
private clamp;
|
|
145
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;
|
|
146
183
|
private ensureDenoiseWorklet;
|
|
147
184
|
private resolveOptions;
|
|
148
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,
|
|
@@ -282,35 +300,35 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
282
300
|
updateSpatialAudio(participantId, position, direction) {
|
|
283
301
|
const nodes = this.participantNodes.get(participantId);
|
|
284
302
|
if (nodes?.panner) {
|
|
285
|
-
const distanceConfig = this.getDistanceConfig();
|
|
286
303
|
const normalizedPosition = this.normalizePositionUnits(position);
|
|
287
|
-
const targetPosition = this.applySpatialBoostIfNeeded(normalizedPosition);
|
|
288
|
-
// Update position (where the sound is coming from)
|
|
289
|
-
nodes.panner.positionX.setValueAtTime(targetPosition.x, this.audioContext.currentTime);
|
|
290
|
-
nodes.panner.positionY.setValueAtTime(targetPosition.y, this.audioContext.currentTime);
|
|
291
|
-
nodes.panner.positionZ.setValueAtTime(targetPosition.z, this.audioContext.currentTime);
|
|
292
|
-
// Update orientation (where the participant is facing)
|
|
293
|
-
// This makes the audio source directional based on participant's direction
|
|
294
|
-
if (direction) {
|
|
295
|
-
// Normalize direction vector
|
|
296
|
-
const length = Math.sqrt(direction.x * direction.x +
|
|
297
|
-
direction.y * direction.y +
|
|
298
|
-
direction.z * direction.z);
|
|
299
|
-
if (length > 0.001) {
|
|
300
|
-
const normX = direction.x / length;
|
|
301
|
-
const normY = direction.y / length;
|
|
302
|
-
const normZ = direction.z / length;
|
|
303
|
-
nodes.panner.orientationX.setValueAtTime(normX, this.audioContext.currentTime);
|
|
304
|
-
nodes.panner.orientationY.setValueAtTime(normY, this.audioContext.currentTime);
|
|
305
|
-
nodes.panner.orientationZ.setValueAtTime(normZ, this.audioContext.currentTime);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
304
|
const listenerPos = this.listenerPosition;
|
|
309
|
-
|
|
310
|
-
const distance = this.getDistanceBetween(listenerPos,
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
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
|
+
}
|
|
314
332
|
}
|
|
315
333
|
}
|
|
316
334
|
/**
|
|
@@ -535,9 +553,45 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
535
553
|
}
|
|
536
554
|
const clarityScore = this.calculateClarityScore(distance, vectorToSource);
|
|
537
555
|
const targetGain = 0.48 + clarityScore * 0.72; // 0.48 → 1.20
|
|
538
|
-
|
|
556
|
+
// Only adjust gain based on angle, not frequency
|
|
539
557
|
nodes.proximityGain.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, 0.08);
|
|
540
|
-
|
|
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
|
+
}
|
|
541
595
|
}
|
|
542
596
|
calculateClarityScore(distance, vectorToSource) {
|
|
543
597
|
const proximityWeight = this.calculateProximityWeight(distance);
|
|
@@ -581,6 +635,132 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
581
635
|
isDenoiserEnabled() {
|
|
582
636
|
return this.options.denoiser?.enabled !== false;
|
|
583
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
|
+
}
|
|
584
764
|
async ensureDenoiseWorklet() {
|
|
585
765
|
if (!this.isDenoiserEnabled()) {
|
|
586
766
|
return;
|
package/package.json
CHANGED