@newgameplusinc/odyssey-audio-video-sdk-dev 1.0.17 → 1.0.19

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/README.md CHANGED
@@ -79,7 +79,7 @@ sdk.setListenerFromLSD(listenerPos, cameraPos, lookAtPos);
79
79
  - **Coordinate normalization** – Unreal sends centimeters; `SpatialAudioManager` auto-detects large values and converts to meters once.
80
80
  - **Orientation math** – `setListenerFromLSD()` builds forward/right/up vectors from camera/LookAt to keep the listener aligned with head movement.
81
81
  - **Dynamic distance gain** – `updateSpatialAudio()` measures distance from listener → source and applies a smooth rolloff curve, so distant avatars fade to silence.
82
- - **Noise handling** – optional AudioWorklet denoiser plus high/low-pass filters trim rumble & hiss before HRTF processing.
82
+ - **Noise handling** – the AudioWorklet denoiser now runs an adaptive multi-band gate (per W3C AudioWorklet guidance) before the high/low-pass filters, stripping constant HVAC/fan noise even when the speaker is close.
83
83
 
84
84
  #### How Spatial Audio Is Built
85
85
  1. **Telemetry ingestion** – each LSD packet is passed through `setListenerFromLSD(listenerPos, cameraPos, lookAtPos)` so the Web Audio listener matches the player’s real head/camera pose.
@@ -11,6 +11,12 @@ type DenoiserOptions = {
11
11
  threshold?: number;
12
12
  noiseFloor?: number;
13
13
  release?: number;
14
+ attack?: number;
15
+ holdMs?: number;
16
+ maxReduction?: number;
17
+ hissCut?: number;
18
+ expansionRatio?: number;
19
+ learnRate?: number;
14
20
  };
15
21
  type SpatialAudioOptions = {
16
22
  distance?: SpatialAudioDistanceConfig;
@@ -25,7 +31,6 @@ export declare class SpatialAudioManager extends EventManager {
25
31
  private options;
26
32
  private denoiseWorkletReady;
27
33
  private denoiseWorkletUrl?;
28
- private denoiserWasmBytes?;
29
34
  private listenerPosition;
30
35
  private listenerInitialized;
31
36
  private listenerDirection;
@@ -96,6 +101,13 @@ export declare class SpatialAudioManager extends EventManager {
96
101
  private getDistanceBetween;
97
102
  private calculateDistanceGain;
98
103
  private normalizePositionUnits;
104
+ private getVectorFromListener;
105
+ private applyDirectionalSuppression;
106
+ private calculateClarityScore;
107
+ private calculateProximityWeight;
108
+ private calculateDirectionFocus;
109
+ private normalizeVector;
110
+ private clamp;
99
111
  private isDenoiserEnabled;
100
112
  private ensureDenoiseWorklet;
101
113
  private resolveOptions;
@@ -69,6 +69,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
69
69
  const panner = this.audioContext.createPanner();
70
70
  const analyser = this.audioContext.createAnalyser();
71
71
  const gain = this.audioContext.createGain();
72
+ const proximityGain = this.audioContext.createGain();
72
73
  let denoiseNode;
73
74
  if (this.isDenoiserEnabled() && typeof this.audioContext.audioWorklet !== "undefined") {
74
75
  try {
@@ -81,9 +82,12 @@ class SpatialAudioManager extends EventManager_1.EventManager {
81
82
  threshold: this.options.denoiser?.threshold,
82
83
  noiseFloor: this.options.denoiser?.noiseFloor,
83
84
  release: this.options.denoiser?.release,
84
- wasmBytes: this.denoiserWasmBytes
85
- ? this.denoiserWasmBytes.slice(0)
86
- : null,
85
+ attack: this.options.denoiser?.attack,
86
+ holdMs: this.options.denoiser?.holdMs,
87
+ maxReduction: this.options.denoiser?.maxReduction,
88
+ hissCut: this.options.denoiser?.hissCut,
89
+ expansionRatio: this.options.denoiser?.expansionRatio,
90
+ learnRate: this.options.denoiser?.learnRate,
87
91
  },
88
92
  });
89
93
  }
@@ -102,6 +106,11 @@ class SpatialAudioManager extends EventManager_1.EventManager {
102
106
  lowpassFilter.type = "lowpass";
103
107
  lowpassFilter.frequency.value = 7500; // Below 8kHz to avoid flat/muffled sound
104
108
  lowpassFilter.Q.value = 1.0; // Quality factor
109
+ const dynamicLowpass = this.audioContext.createBiquadFilter();
110
+ dynamicLowpass.type = "lowpass";
111
+ dynamicLowpass.frequency.value = 7600;
112
+ dynamicLowpass.Q.value = 0.8;
113
+ proximityGain.gain.value = 1.0;
105
114
  // Configure Panner for realistic 3D spatial audio
106
115
  const distanceConfig = this.getDistanceConfig();
107
116
  panner.panningModel = "HRTF"; // Head-Related Transfer Function for realistic 3D
@@ -121,15 +130,17 @@ class SpatialAudioManager extends EventManager_1.EventManager {
121
130
  }
122
131
  currentNode.connect(highpassFilter);
123
132
  highpassFilter.connect(lowpassFilter);
133
+ lowpassFilter.connect(dynamicLowpass);
134
+ dynamicLowpass.connect(proximityGain);
124
135
  if (bypassSpatialization) {
125
136
  console.log(`🔊 TESTING: Connecting audio directly to destination (bypassing spatial audio) for ${participantId}`);
126
- lowpassFilter.connect(analyser);
137
+ proximityGain.connect(analyser);
127
138
  analyser.connect(this.masterGainNode);
128
139
  }
129
140
  else {
130
141
  // Standard spatialized path with full audio chain
131
142
  // Audio Chain: source -> filters -> panner -> analyser -> gain -> masterGain -> compressor -> destination
132
- lowpassFilter.connect(panner);
143
+ proximityGain.connect(panner);
133
144
  panner.connect(analyser);
134
145
  analyser.connect(gain);
135
146
  gain.connect(this.masterGainNode);
@@ -139,8 +150,10 @@ class SpatialAudioManager extends EventManager_1.EventManager {
139
150
  panner,
140
151
  analyser,
141
152
  gain,
153
+ proximityGain,
142
154
  highpassFilter,
143
155
  lowpassFilter,
156
+ dynamicLowpass,
144
157
  denoiseNode,
145
158
  stream,
146
159
  });
@@ -253,7 +266,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
253
266
  }
254
267
  }
255
268
  const listenerPos = this.listenerPosition;
269
+ const vectorToSource = this.getVectorFromListener(targetPosition);
256
270
  const distance = this.getDistanceBetween(listenerPos, targetPosition);
271
+ this.applyDirectionalSuppression(participantId, distance, vectorToSource);
257
272
  const distanceGain = this.calculateDistanceGain(distanceConfig, distance);
258
273
  nodes.gain.gain.setTargetAtTime(distanceGain, this.audioContext.currentTime, 0.05);
259
274
  if (Math.random() < 0.02) {
@@ -473,6 +488,75 @@ class SpatialAudioManager extends EventManager_1.EventManager {
473
488
  }
474
489
  return { ...position };
475
490
  }
491
+ getVectorFromListener(targetPosition) {
492
+ if (!this.listenerInitialized) {
493
+ return { ...targetPosition };
494
+ }
495
+ return {
496
+ x: targetPosition.x - this.listenerPosition.x,
497
+ y: targetPosition.y - this.listenerPosition.y,
498
+ z: targetPosition.z - this.listenerPosition.z,
499
+ };
500
+ }
501
+ applyDirectionalSuppression(participantId, distance, vectorToSource) {
502
+ const nodes = this.participantNodes.get(participantId);
503
+ if (!nodes) {
504
+ return;
505
+ }
506
+ const clarityScore = this.calculateClarityScore(distance, vectorToSource);
507
+ const targetGain = 0.55 + clarityScore * 0.6; // 0.55 → 1.15
508
+ const targetLowpass = 3200 + clarityScore * 4200; // 3.2kHz → ~7.4kHz
509
+ nodes.proximityGain.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, 0.08);
510
+ nodes.dynamicLowpass.frequency.setTargetAtTime(targetLowpass, this.audioContext.currentTime, 0.12);
511
+ if (Math.random() < 0.005) {
512
+ console.log("🎚️ [Spatial Audio] Directional tuning", {
513
+ participantId,
514
+ distance: distance.toFixed(2),
515
+ clarityScore: clarityScore.toFixed(2),
516
+ targetGain: targetGain.toFixed(2),
517
+ lowpassHz: targetLowpass.toFixed(0),
518
+ });
519
+ }
520
+ }
521
+ calculateClarityScore(distance, vectorToSource) {
522
+ const proximityWeight = this.calculateProximityWeight(distance);
523
+ const focusWeight = this.calculateDirectionFocus(vectorToSource);
524
+ return this.clamp(0.2 + proximityWeight * 0.6 + focusWeight * 0.2, 0, 1);
525
+ }
526
+ calculateProximityWeight(distance) {
527
+ const closeRange = 1.2;
528
+ const fadeRange = 12;
529
+ if (distance <= closeRange) {
530
+ return 1;
531
+ }
532
+ if (distance >= fadeRange) {
533
+ return 0;
534
+ }
535
+ return 1 - (distance - closeRange) / (fadeRange - closeRange);
536
+ }
537
+ calculateDirectionFocus(vectorToSource) {
538
+ if (!this.listenerInitialized) {
539
+ return 0.5;
540
+ }
541
+ const forward = this.normalizeVector(this.listenerDirection.forward);
542
+ const source = this.normalizeVector(vectorToSource, { x: 0, y: 0, z: 1 });
543
+ const dot = forward.x * source.x + forward.y * source.y + forward.z * source.z;
544
+ return this.clamp((dot + 1) / 2, 0, 1);
545
+ }
546
+ normalizeVector(vector, fallback = { x: 0, y: 0, z: 1 }) {
547
+ const length = Math.hypot(vector.x, vector.y, vector.z);
548
+ if (length < 1e-4) {
549
+ return { ...fallback };
550
+ }
551
+ return {
552
+ x: vector.x / length,
553
+ y: vector.y / length,
554
+ z: vector.z / length,
555
+ };
556
+ }
557
+ clamp(value, min, max) {
558
+ return Math.min(max, Math.max(min, value));
559
+ }
476
560
  isDenoiserEnabled() {
477
561
  return this.options.denoiser?.enabled !== false;
478
562
  }
@@ -496,47 +580,101 @@ class SpatialAudioManager extends EventManager_1.EventManager {
496
580
  super();
497
581
  const cfg = (options && options.processorOptions) || {};
498
582
  this.enabled = cfg.enabled !== false;
499
- this.threshold = typeof cfg.threshold === 'number' ? cfg.threshold : 0.012;
500
- this.noiseFloor = typeof cfg.noiseFloor === 'number' ? cfg.noiseFloor : 0.004;
501
- this.release = typeof cfg.release === 'number' ? cfg.release : 0.18;
502
- this.smoothedLevel = this.noiseFloor;
583
+ this.threshold = this._sanitize(cfg.threshold, 0.003, 0.05, 0.012);
584
+ this.noiseFloor = this._sanitize(cfg.noiseFloor, 0.0005, 0.05, 0.004);
585
+ this.attack = this._sanitize(cfg.attack, 0.01, 0.9, 0.35);
586
+ this.release = this._sanitize(cfg.release, 0.01, 0.9, 0.18);
587
+ this.holdSamples = Math.max(
588
+ 8,
589
+ Math.round(
590
+ sampleRate * this._sanitize(cfg.holdMs, 10, 400, 110) / 1000
591
+ )
592
+ );
593
+ this.maxReduction = this._sanitize(cfg.maxReduction, 0.1, 0.95, 0.85);
594
+ this.hissCut = this._sanitize(cfg.hissCut, 0, 1, 0.45);
595
+ this.expansionRatio = this._sanitize(cfg.expansionRatio, 1.1, 4, 1.8);
596
+ this.learnRate = this._sanitize(cfg.learnRate, 0.001, 0.3, 0.08);
597
+ this.channelState = [];
598
+ this.hfAlpha = Math.exp(-2 * Math.PI * 3200 / sampleRate);
599
+ }
600
+
601
+ _sanitize(value, min, max, fallback) {
602
+ if (typeof value !== 'number' || !isFinite(value)) {
603
+ return fallback;
604
+ }
605
+ return Math.min(max, Math.max(min, value));
606
+ }
607
+
608
+ _ensureState(index) {
609
+ if (!this.channelState[index]) {
610
+ this.channelState[index] = {
611
+ envelope: this.noiseFloor,
612
+ noise: this.noiseFloor,
613
+ gain: 1,
614
+ quietSamples: 0,
615
+ lpState: 0,
616
+ };
617
+ }
618
+ return this.channelState[index];
503
619
  }
504
620
 
505
621
  process(inputs, outputs) {
506
622
  const input = inputs[0];
507
623
  const output = outputs[0];
508
- if (!input || input.length === 0 || !output || output.length === 0) {
624
+ if (!input || !output) {
509
625
  return true;
510
626
  }
511
627
 
512
- for (let channel = 0; channel < input.length; channel++) {
628
+ for (let channel = 0; channel < output.length; channel++) {
513
629
  const inChannel = input[channel];
514
630
  const outChannel = output[channel];
515
631
  if (!inChannel || !outChannel) {
516
632
  continue;
517
633
  }
518
634
 
519
- let sum = 0;
635
+ if (!this.enabled) {
636
+ for (let i = 0; i < inChannel.length; i++) {
637
+ outChannel[i] = inChannel[i];
638
+ }
639
+ continue;
640
+ }
641
+
642
+ const state = this._ensureState(channel);
643
+
520
644
  for (let i = 0; i < inChannel.length; i++) {
521
645
  const sample = inChannel[i];
522
- sum += sample * sample;
523
- }
646
+ const magnitude = Math.abs(sample);
524
647
 
525
- const rms = Math.sqrt(sum / inChannel.length);
526
- this.smoothedLevel += (rms - this.smoothedLevel) * this.release;
527
- const dynamicThreshold = Math.max(
528
- this.noiseFloor,
529
- this.threshold * 0.6 + this.smoothedLevel * 0.4
530
- );
648
+ state.envelope += (magnitude - state.envelope) * this.attack;
531
649
 
532
- if (!this.enabled || rms >= dynamicThreshold) {
533
- for (let i = 0; i < inChannel.length; i++) {
534
- outChannel[i] = inChannel[i];
650
+ if (state.envelope < this.threshold) {
651
+ state.noise += (state.envelope - state.noise) * this.learnRate;
652
+ state.quietSamples++;
653
+ } else {
654
+ state.quietSamples = 0;
535
655
  }
536
- } else {
537
- for (let i = 0; i < inChannel.length; i++) {
538
- outChannel[i] = 0;
656
+
657
+ const ratio = state.noise / Math.max(state.envelope, 1e-6);
658
+ let gainTarget = 1 - Math.min(0.98, Math.pow(ratio, this.expansionRatio));
659
+ gainTarget = Math.max(0, Math.min(1, gainTarget));
660
+
661
+ if (state.quietSamples > this.holdSamples) {
662
+ gainTarget *= 1 - this.maxReduction;
539
663
  }
664
+
665
+ state.gain += (gainTarget - state.gain) * this.release;
666
+ let processed = sample * state.gain;
667
+
668
+ state.lpState = this.hfAlpha * state.lpState + (1 - this.hfAlpha) * processed;
669
+ const highComponent = processed - state.lpState;
670
+ const hissRatio = Math.min(
671
+ 1,
672
+ Math.abs(highComponent) / (Math.abs(state.lpState) + 1e-5)
673
+ );
674
+ const hissGain = 1 - hissRatio * this.hissCut;
675
+ processed = state.lpState + highComponent * hissGain;
676
+
677
+ outChannel[i] = processed;
540
678
  }
541
679
  }
542
680
 
@@ -574,6 +712,12 @@ registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
574
712
  threshold: 0.012,
575
713
  noiseFloor: 0.004,
576
714
  release: 0.18,
715
+ attack: 0.35,
716
+ holdMs: 110,
717
+ maxReduction: 0.85,
718
+ hissCut: 0.45,
719
+ expansionRatio: 1.8,
720
+ learnRate: 0.08,
577
721
  };
578
722
  return {
579
723
  distance: {
@@ -587,6 +731,12 @@ registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
587
731
  threshold: options?.denoiser?.threshold ?? denoiserDefaults.threshold,
588
732
  noiseFloor: options?.denoiser?.noiseFloor ?? denoiserDefaults.noiseFloor,
589
733
  release: options?.denoiser?.release ?? denoiserDefaults.release,
734
+ attack: options?.denoiser?.attack ?? denoiserDefaults.attack,
735
+ holdMs: options?.denoiser?.holdMs ?? denoiserDefaults.holdMs,
736
+ maxReduction: options?.denoiser?.maxReduction ?? denoiserDefaults.maxReduction,
737
+ hissCut: options?.denoiser?.hissCut ?? denoiserDefaults.hissCut,
738
+ expansionRatio: options?.denoiser?.expansionRatio ?? denoiserDefaults.expansionRatio,
739
+ learnRate: options?.denoiser?.learnRate ?? denoiserDefaults.learnRate,
590
740
  },
591
741
  };
592
742
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newgameplusinc/odyssey-audio-video-sdk-dev",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
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",