@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 +1 -1
- package/dist/SpatialAudioManager.d.ts +13 -1
- package/dist/SpatialAudioManager.js +176 -26
- package/package.json +1 -1
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** –
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
500
|
-
this.noiseFloor =
|
|
501
|
-
this.
|
|
502
|
-
this.
|
|
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 ||
|
|
624
|
+
if (!input || !output) {
|
|
509
625
|
return true;
|
|
510
626
|
}
|
|
511
627
|
|
|
512
|
-
for (let channel = 0; 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
|
-
|
|
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
|
-
|
|
523
|
-
}
|
|
646
|
+
const magnitude = Math.abs(sample);
|
|
524
647
|
|
|
525
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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