@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.
@@ -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 = 85; // Conservative value to preserve male voice depth
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 = 7500; // Below 8kHz to avoid flat/muffled sound
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 = 7600;
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(lowpassFilter);
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
- const vectorToSource = this.getVectorFromListener(targetPosition);
310
- const distance = this.getDistanceBetween(listenerPos, targetPosition);
311
- this.applyDirectionalSuppression(participantId, distance, vectorToSource);
312
- const distanceGain = this.calculateDistanceGain(distanceConfig, distance);
313
- nodes.gain.gain.setTargetAtTime(distanceGain, this.audioContext.currentTime, 0.05);
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
- const targetLowpass = 3600 + clarityScore * 4600; // 3.6kHz → ~8.2kHz
556
+ // Only adjust gain based on angle, not frequency
539
557
  nodes.proximityGain.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, 0.08);
540
- nodes.dynamicLowpass.frequency.setTargetAtTime(targetLowpass, this.audioContext.currentTime, 0.12);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newgameplusinc/odyssey-audio-video-sdk-dev",
3
- "version": "1.0.61",
3
+ "version": "1.0.62",
4
4
  "description": "Odyssey Spatial Audio & Video SDK using MediaSoup for real-time communication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",