@newgameplusinc/odyssey-audio-video-sdk-dev 1.0.12 β 1.0.13
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 +5 -3
- package/dist/SpatialAudioManager.d.ts +10 -0
- package/dist/SpatialAudioManager.js +34 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ It mirrors the production SDK used by Odyssey V2 and ships ready-to-drop into an
|
|
|
13
13
|
- π§ **Accurate pose propagation** β `updatePosition()` streams listener pose to the SFU while `participant-position-updated` keeps the local store in sync.
|
|
14
14
|
- π§ **Studio-grade spatial audio** β each remote participant gets a dedicated Web Audio graph: denoiser β high-pass β low-pass β HRTF `PannerNode` β adaptive gain β master compressor.
|
|
15
15
|
- π₯ **Camera-ready streams** β video tracks are exposed separately so UI layers can render muted `<video>` tags while audio stays inside Web Audio.
|
|
16
|
-
- ποΈ **Clean microphone uplink** β
|
|
16
|
+
- ποΈ **Clean microphone uplink (optβin)** β when `outboundTuning.enabled=true`, `enhanceOutgoingAudioTrack` runs mic input through denoiser + EQ + compressor before hitting the SFU.
|
|
17
17
|
- π **EventEmitter contract** β subscribe to `room-joined`, `consumer-created`, `participant-position-updated`, etc., without touching Socket.IO directly.
|
|
18
18
|
|
|
19
19
|
## Quick Start
|
|
@@ -81,6 +81,7 @@ sdk.setListenerFromLSD(listenerPos, cameraPos, lookAtPos);
|
|
|
81
81
|
- **Orientation math** β `setListenerFromLSD()` builds forward/right/up vectors from camera/LookAt to keep the listener aligned with head movement.
|
|
82
82
|
- **Dynamic distance gain** β `updateSpatialAudio()` measures distance from listener β source and applies a smooth rolloff curve, so distant avatars fade to silence.
|
|
83
83
|
- **Noise handling** β optional AudioWorklet denoiser plus high/low-pass filters trim rumble & hiss before HRTF processing.
|
|
84
|
+
- **Dynamic gate (opt-in)** β enable via `noiseGate.enabled=true` to let the SDK automatically clamp remote tracks when theyβre idle.
|
|
84
85
|
|
|
85
86
|
#### How Spatial Audio Is Built
|
|
86
87
|
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.
|
|
@@ -90,11 +91,12 @@ sdk.setListenerFromLSD(listenerPos, cameraPos, lookAtPos);
|
|
|
90
91
|
4. **Distance-aware gain** β the manager stores the latest listener pose and computes the Euclidean distance to each remote participant on every update. A custom rolloff curve adjusts gain before the compressor, giving the βsomeone on my left / far awayβ perception without blowing out master levels.
|
|
91
92
|
5. **Left/right rendering** β because the panner uses `panningModel = "HRTF"`, browsers feed the processed signal into the userβs audio hardware with head-related transfer functions, producing natural interaural time/intensity differences.
|
|
92
93
|
|
|
93
|
-
#### How Microphone Audio Is Tuned Before Sending
|
|
94
|
+
#### How Microphone Audio Is Tuned Before Sending (Opt-In)
|
|
95
|
+
> Disabled by default. Enable via `new SpatialAudioManager({ outboundTuning: { enabled: true } })`.
|
|
94
96
|
1. **Hardware constraints first** β the SDK requests `noiseSuppression`, `echoCancellation`, and `autoGainControl` on the raw `MediaStreamTrack` (plus Chromium-specific `goog*` flags).
|
|
95
97
|
2. **Web Audio pre-flight** β `enhanceOutgoingAudioTrack(track)` clones the mic into a dedicated `AudioContext` and chain: `Denoiser β 50/60β―Hz notches β Low-shelf rumble cut β High-pass (95β―Hz) β Low-pass (7.2β―kHz) β High-shelf tame β Presence boost β Dynamics compressor β Adaptive gate`.
|
|
96
98
|
3. **Adaptive gate** β a lightweight RMS monitor clamps the gate gain when only background hiss remains, but opens instantly when speech energy rises.
|
|
97
|
-
4. **Clean stream to SFU** β the processed track is what you pass to `produceTrack`, so every participant receives the filtered audio (and your local store uses the same track for mute toggles).
|
|
99
|
+
4. **Clean stream to SFU** β the processed track is what you pass to `produceTrack`, so every participant receives the filtered audio (and your local store uses the same track for mute toggles). Toggle the feature off to fall back to raw WebRTC audio instantly.
|
|
98
100
|
|
|
99
101
|
## Video Flow (Capture β Rendering)
|
|
100
102
|
|
|
@@ -12,9 +12,17 @@ type DenoiserOptions = {
|
|
|
12
12
|
noiseFloor?: number;
|
|
13
13
|
release?: number;
|
|
14
14
|
};
|
|
15
|
+
type NoiseGateOptions = {
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
};
|
|
18
|
+
type OutboundTuningOptions = {
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
};
|
|
15
21
|
type SpatialAudioOptions = {
|
|
16
22
|
distance?: SpatialAudioDistanceConfig;
|
|
17
23
|
denoiser?: DenoiserOptions;
|
|
24
|
+
noiseGate?: NoiseGateOptions;
|
|
25
|
+
outboundTuning?: OutboundTuningOptions;
|
|
18
26
|
};
|
|
19
27
|
export declare class SpatialAudioManager extends EventManager {
|
|
20
28
|
private audioContext;
|
|
@@ -31,6 +39,8 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
31
39
|
private stabilityState;
|
|
32
40
|
private outgoingProcessors;
|
|
33
41
|
private listenerDirection;
|
|
42
|
+
private noiseGateEnabled;
|
|
43
|
+
private outboundTuningEnabled;
|
|
34
44
|
constructor(options?: SpatialAudioOptions);
|
|
35
45
|
getAudioContext(): AudioContext;
|
|
36
46
|
/**
|
|
@@ -17,6 +17,8 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
17
17
|
up: { x: 0, y: 0, z: 1 },
|
|
18
18
|
};
|
|
19
19
|
this.options = this.resolveOptions(options);
|
|
20
|
+
this.noiseGateEnabled = this.options.noiseGate?.enabled ?? false;
|
|
21
|
+
this.outboundTuningEnabled = this.options.outboundTuning?.enabled ?? false;
|
|
20
22
|
// Use high sample rate for best audio quality
|
|
21
23
|
this.audioContext = new AudioContext({ sampleRate: 48000 });
|
|
22
24
|
// Master gain
|
|
@@ -126,16 +128,20 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
126
128
|
}
|
|
127
129
|
currentNode.connect(highpassFilter);
|
|
128
130
|
highpassFilter.connect(lowpassFilter);
|
|
129
|
-
lowpassFilter
|
|
131
|
+
let postFilterNode = lowpassFilter;
|
|
132
|
+
if (this.noiseGateEnabled) {
|
|
133
|
+
lowpassFilter.connect(noiseGate);
|
|
134
|
+
postFilterNode = noiseGate;
|
|
135
|
+
}
|
|
130
136
|
if (bypassSpatialization) {
|
|
131
137
|
console.log(`π TESTING: Connecting audio directly to destination (bypassing spatial audio) for ${participantId}`);
|
|
132
|
-
|
|
138
|
+
postFilterNode.connect(analyser);
|
|
133
139
|
analyser.connect(this.masterGainNode);
|
|
134
140
|
}
|
|
135
141
|
else {
|
|
136
142
|
// Standard spatialized path with full audio chain
|
|
137
|
-
// Audio Chain: source -> filters ->
|
|
138
|
-
|
|
143
|
+
// Audio Chain: source -> filters -> (optional gate) -> panner -> analyser -> gain -> masterGain -> compressor -> destination
|
|
144
|
+
postFilterNode.connect(panner);
|
|
139
145
|
panner.connect(analyser);
|
|
140
146
|
analyser.connect(gain);
|
|
141
147
|
gain.connect(this.masterGainNode);
|
|
@@ -156,7 +162,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
156
162
|
targetGain: 1,
|
|
157
163
|
networkMuted: false,
|
|
158
164
|
});
|
|
159
|
-
if (typeof track.onmute !== "undefined") {
|
|
165
|
+
if (this.noiseGateEnabled && typeof track.onmute !== "undefined") {
|
|
160
166
|
track.onmute = () => this.handleTrackStability(participantId, true);
|
|
161
167
|
track.onunmute = () => this.handleTrackStability(participantId, false);
|
|
162
168
|
}
|
|
@@ -176,11 +182,13 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
176
182
|
rolloffFactor: panner.rolloffFactor,
|
|
177
183
|
},
|
|
178
184
|
});
|
|
179
|
-
// Start monitoring audio levels
|
|
180
|
-
this.
|
|
185
|
+
// Start monitoring audio levels if gate enabled
|
|
186
|
+
if (this.noiseGateEnabled) {
|
|
187
|
+
this.startMonitoring(participantId);
|
|
188
|
+
}
|
|
181
189
|
}
|
|
182
190
|
async enhanceOutgoingAudioTrack(track) {
|
|
183
|
-
if (track.kind !== "audio") {
|
|
191
|
+
if (track.kind !== "audio" || !this.outboundTuningEnabled) {
|
|
184
192
|
return track;
|
|
185
193
|
}
|
|
186
194
|
const existingProcessor = Array.from(this.outgoingProcessors.values()).find((processor) => processor.originalTrack === track);
|
|
@@ -305,6 +313,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
305
313
|
return processedTrack;
|
|
306
314
|
}
|
|
307
315
|
startMonitoring(participantId) {
|
|
316
|
+
if (!this.noiseGateEnabled) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
308
319
|
const nodes = this.participantNodes.get(participantId);
|
|
309
320
|
if (!nodes)
|
|
310
321
|
return;
|
|
@@ -380,6 +391,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
380
391
|
this.monitoringIntervals.set(participantId, interval);
|
|
381
392
|
}
|
|
382
393
|
handleTrackStability(participantId, muted) {
|
|
394
|
+
if (!this.noiseGateEnabled) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
383
397
|
const nodes = this.participantNodes.get(participantId);
|
|
384
398
|
if (!nodes)
|
|
385
399
|
return;
|
|
@@ -729,12 +743,6 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
729
743
|
clearInterval(processor.monitor);
|
|
730
744
|
processor.processedTrack.removeEventListener("ended", processor.cleanupListener);
|
|
731
745
|
processor.originalTrack.removeEventListener("ended", processor.cleanupListener);
|
|
732
|
-
try {
|
|
733
|
-
processor.originalTrack.stop();
|
|
734
|
-
}
|
|
735
|
-
catch (error) {
|
|
736
|
-
console.warn("β οΈ Unable to stop original track during cleanup", error);
|
|
737
|
-
}
|
|
738
746
|
processor.destinationStream.getTracks().forEach((t) => t.stop());
|
|
739
747
|
processor.sourceStream.getTracks().forEach((t) => t.stop());
|
|
740
748
|
processor.context.close();
|
|
@@ -843,6 +851,12 @@ registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
|
|
|
843
851
|
noiseFloor: 0.004,
|
|
844
852
|
release: 0.18,
|
|
845
853
|
};
|
|
854
|
+
const noiseGateDefaults = {
|
|
855
|
+
enabled: false,
|
|
856
|
+
};
|
|
857
|
+
const outboundDefaults = {
|
|
858
|
+
enabled: false,
|
|
859
|
+
};
|
|
846
860
|
return {
|
|
847
861
|
distance: {
|
|
848
862
|
refDistance: options?.distance?.refDistance ?? distanceDefaults.refDistance,
|
|
@@ -856,6 +870,12 @@ registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
|
|
|
856
870
|
noiseFloor: options?.denoiser?.noiseFloor ?? denoiserDefaults.noiseFloor,
|
|
857
871
|
release: options?.denoiser?.release ?? denoiserDefaults.release,
|
|
858
872
|
},
|
|
873
|
+
noiseGate: {
|
|
874
|
+
enabled: options?.noiseGate?.enabled ?? noiseGateDefaults.enabled,
|
|
875
|
+
},
|
|
876
|
+
outboundTuning: {
|
|
877
|
+
enabled: options?.outboundTuning?.enabled ?? outboundDefaults.enabled,
|
|
878
|
+
},
|
|
859
879
|
};
|
|
860
880
|
}
|
|
861
881
|
}
|
package/package.json
CHANGED