@newgameplusinc/odyssey-audio-video-sdk-dev 1.0.11 β 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 +133 -94
- package/dist/SpatialAudioManager.d.ts +18 -1
- package/dist/SpatialAudioManager.js +313 -25
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,106 +1,145 @@
|
|
|
1
|
-
# Odyssey
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
### 2. Join a Room with User Profile Data
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
const participant = await sdk.joinRoom({
|
|
42
|
-
roomId: "my-room",
|
|
43
|
-
userId: "user-123",
|
|
44
|
-
deviceId: "device-456",
|
|
45
|
-
position: { x: 0, y: 0, z: 0 },
|
|
46
|
-
direction: { x: 0, y: 0, z: 1 },
|
|
47
|
-
bodyHeight: "0.5", // User's avatar height from Firebase
|
|
48
|
-
bodyShape: "4", // User's avatar body shape from Firebase
|
|
49
|
-
userName: "John Doe", // User's display name
|
|
50
|
-
userEmail: "john@example.com" // User's email
|
|
1
|
+
# Odyssey Audio/Video SDK (MediaSoup + Web Audio)
|
|
2
|
+
|
|
3
|
+
This package exposes `OdysseySpatialComms`, a thin TypeScript client that glues together:
|
|
4
|
+
|
|
5
|
+
- **MediaSoup SFU** for ultra-low-latency audio/video routing
|
|
6
|
+
- **Web Audio API** for Apple-like spatial mixing via `SpatialAudioManager`
|
|
7
|
+
- **Socket telemetry** (position + direction) so every browser hears/see everyone exactly where they are in the 3D world
|
|
8
|
+
|
|
9
|
+
It mirrors the production SDK used by Odyssey V2 and ships ready-to-drop into any Web UI (Vue, React, plain JS).
|
|
10
|
+
|
|
11
|
+
## Feature Highlights
|
|
12
|
+
- π **One class to rule it all** β `OdysseySpatialComms` wires transports, producers, consumers, and room state.
|
|
13
|
+
- π§ **Accurate pose propagation** β `updatePosition()` streams listener pose to the SFU while `participant-position-updated` keeps the local store in sync.
|
|
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
|
+
- π₯ **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 (optβin)** β when `outboundTuning.enabled=true`, `enhanceOutgoingAudioTrack` runs mic input through denoiser + EQ + compressor before hitting the SFU.
|
|
17
|
+
- π **EventEmitter contract** β subscribe to `room-joined`, `consumer-created`, `participant-position-updated`, etc., without touching Socket.IO directly.
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
OdysseySpatialComms,
|
|
24
|
+
Direction,
|
|
25
|
+
Position,
|
|
26
|
+
} from "@newgameplusinc/odyssey-audio-video-sdk-dev";
|
|
27
|
+
|
|
28
|
+
const sdk = new OdysseySpatialComms("https://mediasoup-server.example.com");
|
|
29
|
+
|
|
30
|
+
// 1) Join a room
|
|
31
|
+
await sdk.joinRoom({
|
|
32
|
+
roomId: "demo-room",
|
|
33
|
+
userId: "user-123",
|
|
34
|
+
deviceId: "device-123",
|
|
35
|
+
position: { x: 0, y: 0, z: 0 },
|
|
36
|
+
direction: { x: 0, y: 1, z: 0 },
|
|
51
37
|
});
|
|
52
|
-
```
|
|
53
38
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
39
|
+
// 2) Produce local media
|
|
40
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
|
41
|
+
for (const track of stream.getTracks()) {
|
|
42
|
+
await sdk.produceTrack(track);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3) Handle remote tracks
|
|
46
|
+
sdk.on("consumer-created", async ({ participant, track }) => {
|
|
47
|
+
if (track.kind === "video") {
|
|
48
|
+
attachVideo(track, participant.participantId);
|
|
49
|
+
}
|
|
61
50
|
});
|
|
62
51
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// Produce video track
|
|
68
|
-
const videoTrack = stream.getVideoTracks()[0];
|
|
69
|
-
await sdk.produceTrack(videoTrack);
|
|
52
|
+
// 4) Keep spatial audio honest
|
|
53
|
+
sdk.updatePosition(currentPos, currentDir);
|
|
54
|
+
sdk.setListenerFromLSD(listenerPos, cameraPos, lookAtPos);
|
|
70
55
|
```
|
|
71
56
|
|
|
72
|
-
|
|
57
|
+
## Audio Flow (Server β Browser)
|
|
73
58
|
|
|
74
|
-
```typescript
|
|
75
|
-
sdk.updatePosition(
|
|
76
|
-
{ x: 10, y: 0, z: 5 }, // New position
|
|
77
|
-
{ x: 0, y: 0, z: 1 } // New direction
|
|
78
|
-
);
|
|
79
59
|
```
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
});
|
|
60
|
+
ββββββββββββββββ update-position ββββββββββββββββ pose + tracks ββββββββββββββββββββ
|
|
61
|
+
β Browser LSD β βββββββββββββββββββΆ β MediaSoup SFUβ βββββββββββββββββΆ β SDK Event Bus β
|
|
62
|
+
β (Unreal data)β β + Socket.IO β β (EventManager) β
|
|
63
|
+
ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββββββ¬βββββββββ
|
|
64
|
+
β β track + pose
|
|
65
|
+
β β βΌ
|
|
66
|
+
β ββββββββββΌβββββββββ ββββββββββββββββββββ
|
|
67
|
+
β audio RTP β consumer-createdβ β SpatialAudioMgr β
|
|
68
|
+
ββββββββββββββββββββββββββββΆβ setup per-user βββββββββββββββββββββββββ (Web Audio API) β
|
|
69
|
+
ββββββββββ¬βββββββββ β - Denoiser β
|
|
70
|
+
β β - HP / LP β
|
|
71
|
+
β β - HRTF Panner β
|
|
72
|
+
βΌ β - Gain + Comp β
|
|
73
|
+
Web Audio Graph ββββββββββββ¬ββββββββ
|
|
74
|
+
β β
|
|
75
|
+
βΌ βΌ
|
|
76
|
+
Listener ears (Left/Right) System Output
|
|
98
77
|
```
|
|
99
78
|
|
|
100
|
-
|
|
79
|
+
### Web Audio Algorithms
|
|
80
|
+
- **Coordinate normalization** β Unreal sends centimeters; `SpatialAudioManager` auto-detects large values and converts to meters once.
|
|
81
|
+
- **Orientation math** β `setListenerFromLSD()` builds forward/right/up vectors from camera/LookAt to keep the listener aligned with head movement.
|
|
82
|
+
- **Dynamic distance gain** β `updateSpatialAudio()` measures distance from listener β source and applies a smooth rolloff curve, so distant avatars fade to silence.
|
|
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.
|
|
85
|
+
|
|
86
|
+
#### How Spatial Audio Is Built
|
|
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.
|
|
88
|
+
2. **Per-participant node graph** β when `consumer-created` yields a remote audio track, `setupSpatialAudioForParticipant()` spins up an isolated graph:
|
|
89
|
+
`MediaStreamSource β (optional) Denoiser Worklet β High-Pass β Low-Pass β Panner(HRTF) β Gain β Master Compressor`.
|
|
90
|
+
3. **Position + direction updates** β every `participant-position-updated` event calls `updateSpatialAudio(participantId, position, direction)`. The position feeds the pannerβs XYZ, while the direction vector sets the source orientation so voices project forward relative to avatar facing.
|
|
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.
|
|
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.
|
|
93
|
+
|
|
94
|
+
#### How Microphone Audio Is Tuned Before Sending (Opt-In)
|
|
95
|
+
> Disabled by default. Enable via `new SpatialAudioManager({ outboundTuning: { enabled: true } })`.
|
|
96
|
+
1. **Hardware constraints first** β the SDK requests `noiseSuppression`, `echoCancellation`, and `autoGainControl` on the raw `MediaStreamTrack` (plus Chromium-specific `goog*` flags).
|
|
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`.
|
|
98
|
+
3. **Adaptive gate** β a lightweight RMS monitor clamps the gate gain when only background hiss remains, but opens instantly when speech energy rises.
|
|
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.
|
|
100
|
+
|
|
101
|
+
## Video Flow (Capture β Rendering)
|
|
101
102
|
|
|
102
|
-
To build the package, run:
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
npm run build
|
|
106
103
|
```
|
|
104
|
+
ββββββββββββββββ produceTrack ββββββββββββββββ RTP ββββββββββββββββ
|
|
105
|
+
β getUserMedia β ββββββββββββββββΆ β MediaSoup SDKβ βββββββΆ β MediaSoup SFUβ
|
|
106
|
+
ββββββββ¬ββββββββ β (Odyssey) β ββββββββ¬ββββββββ
|
|
107
|
+
β ββββββββ¬ββββββββ β
|
|
108
|
+
β consumer-created β track β
|
|
109
|
+
βΌ βΌ β
|
|
110
|
+
ββββββββββββββββ ββββββββββββββββ β
|
|
111
|
+
β Vue/React UI β ββββββββββββββββ β SDK Event Bus β ββββββββββββββββ
|
|
112
|
+
β (muted video β β exposes media β
|
|
113
|
+
β elements) β β tracks β
|
|
114
|
+
ββββββββββββββββ ββββββββββββββββ
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Core Classes
|
|
118
|
+
- `src/index.ts` β `OdysseySpatialComms` (socket lifecycle, producers/consumers, event surface).
|
|
119
|
+
- `src/MediasoupManager.ts` β transport helpers for produce/consume/resume.
|
|
120
|
+
- `src/SpatialAudioManager.ts` β Web Audio orchestration (listener transforms, per-participant chains, denoiser, distance math).
|
|
121
|
+
- `src/EventManager.ts` β lightweight EventEmitter used by the entire SDK.
|
|
122
|
+
|
|
123
|
+
## Integration Checklist
|
|
124
|
+
1. **Instantiate once** per page/tab and keep it in a store (Vuex, Redux, Zustand, etc.).
|
|
125
|
+
2. **Pipe LSD/Lap data** from your rendering engine into `updatePosition()` + `setListenerFromLSD()` at ~10 Hz.
|
|
126
|
+
3. **Render videos muted** β never attach remote audio tracks straight to DOM; let `SpatialAudioManager` own playback.
|
|
127
|
+
4. **Push avatar telemetry back to Unreal** so `remoteSpatialData` can render minimaps/circles (see Odyssey V2 `sendMediaSoupParticipantsToUnreal`).
|
|
128
|
+
5. **Monitor logs** β browser console shows `π§ SDK`, `π SDK`, and `ποΈ [Spatial Audio]` statements for every critical hop.
|
|
129
|
+
|
|
130
|
+
## Server Contract (Socket.IO events)
|
|
131
|
+
| Event | Direction | Payload |
|
|
132
|
+
|-------|-----------|---------|
|
|
133
|
+
| `join-room` | client β server | `{roomId, userId, deviceId, position, direction}` |
|
|
134
|
+
| `room-joined` | server β client | `RoomJoinedData` (router caps, participants snapshot) |
|
|
135
|
+
| `update-position` | client β server | `{participantId, conferenceId, position, direction}` |
|
|
136
|
+
| `participant-position-updated` | server β client | `{participantId, position, direction, mediaState}` |
|
|
137
|
+
| `consumer-created` | server β client | `{participantId, track(kind), position, direction}` |
|
|
138
|
+
| `participant-media-state-updated` | server β client | `{participantId, mediaState}` |
|
|
139
|
+
|
|
140
|
+
## Development Tips
|
|
141
|
+
- Run `pnpm install && pnpm build` inside `mediasoup-sdk-test` to publish a fresh build.
|
|
142
|
+
- Use `pnpm watch` while iterating so TypeScript outputs live under `dist/`.
|
|
143
|
+
- The SDK targets evergreen browsers; for Safari <16.4 you may need to polyfill AudioWorklets or disable the denoiser via `new SpatialAudioManager({ denoiser: { enabled: false } })`.
|
|
144
|
+
|
|
145
|
+
Have questions or want to extend the SDK? Start with `SpatialAudioManager` β thatβs where most of the βreal-worldβ behavior (distance feel, stereo cues, denoiser) lives.
|
|
@@ -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;
|
|
@@ -23,12 +31,16 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
23
31
|
private monitoringIntervals;
|
|
24
32
|
private compressor;
|
|
25
33
|
private options;
|
|
26
|
-
private denoiseWorkletReady;
|
|
27
34
|
private denoiseWorkletUrl?;
|
|
28
35
|
private denoiserWasmBytes?;
|
|
36
|
+
private denoiseContextPromises;
|
|
29
37
|
private listenerPosition;
|
|
30
38
|
private listenerInitialized;
|
|
39
|
+
private stabilityState;
|
|
40
|
+
private outgoingProcessors;
|
|
31
41
|
private listenerDirection;
|
|
42
|
+
private noiseGateEnabled;
|
|
43
|
+
private outboundTuningEnabled;
|
|
32
44
|
constructor(options?: SpatialAudioOptions);
|
|
33
45
|
getAudioContext(): AudioContext;
|
|
34
46
|
/**
|
|
@@ -47,7 +59,9 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
47
59
|
* @param bypassSpatialization For testing - bypasses 3D positioning
|
|
48
60
|
*/
|
|
49
61
|
setupSpatialAudioForParticipant(participantId: string, track: MediaStreamTrack, bypassSpatialization?: boolean): Promise<void>;
|
|
62
|
+
enhanceOutgoingAudioTrack(track: MediaStreamTrack): Promise<MediaStreamTrack>;
|
|
50
63
|
private startMonitoring;
|
|
64
|
+
private handleTrackStability;
|
|
51
65
|
/**
|
|
52
66
|
* Update spatial audio position and orientation for a participant
|
|
53
67
|
*
|
|
@@ -97,6 +111,9 @@ export declare class SpatialAudioManager extends EventManager {
|
|
|
97
111
|
private calculateDistanceGain;
|
|
98
112
|
private normalizePositionUnits;
|
|
99
113
|
private isDenoiserEnabled;
|
|
114
|
+
private applyHardwareNoiseConstraints;
|
|
115
|
+
private startOutboundMonitor;
|
|
116
|
+
private cleanupOutboundProcessor;
|
|
100
117
|
private ensureDenoiseWorklet;
|
|
101
118
|
private resolveOptions;
|
|
102
119
|
}
|
|
@@ -7,14 +7,18 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
7
7
|
super();
|
|
8
8
|
this.participantNodes = new Map();
|
|
9
9
|
this.monitoringIntervals = new Map();
|
|
10
|
-
this.
|
|
10
|
+
this.denoiseContextPromises = new WeakMap();
|
|
11
11
|
this.listenerPosition = { x: 0, y: 0, z: 0 };
|
|
12
12
|
this.listenerInitialized = false;
|
|
13
|
+
this.stabilityState = new Map();
|
|
14
|
+
this.outgoingProcessors = new Map();
|
|
13
15
|
this.listenerDirection = {
|
|
14
16
|
forward: { x: 0, y: 1, z: 0 },
|
|
15
17
|
up: { x: 0, y: 0, z: 1 },
|
|
16
18
|
};
|
|
17
19
|
this.options = this.resolveOptions(options);
|
|
20
|
+
this.noiseGateEnabled = this.options.noiseGate?.enabled ?? false;
|
|
21
|
+
this.outboundTuningEnabled = this.options.outboundTuning?.enabled ?? false;
|
|
18
22
|
// Use high sample rate for best audio quality
|
|
19
23
|
this.audioContext = new AudioContext({ sampleRate: 48000 });
|
|
20
24
|
// Master gain
|
|
@@ -69,6 +73,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
69
73
|
const panner = this.audioContext.createPanner();
|
|
70
74
|
const analyser = this.audioContext.createAnalyser();
|
|
71
75
|
const gain = this.audioContext.createGain();
|
|
76
|
+
const noiseGate = this.audioContext.createGain();
|
|
72
77
|
let denoiseNode;
|
|
73
78
|
if (this.isDenoiserEnabled() && typeof this.audioContext.audioWorklet !== "undefined") {
|
|
74
79
|
try {
|
|
@@ -102,6 +107,8 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
102
107
|
lowpassFilter.type = "lowpass";
|
|
103
108
|
lowpassFilter.frequency.value = 7500; // Below 8kHz to avoid flat/muffled sound
|
|
104
109
|
lowpassFilter.Q.value = 1.0; // Quality factor
|
|
110
|
+
// Adaptive noise gate defaults
|
|
111
|
+
noiseGate.gain.value = 1.0;
|
|
105
112
|
// Configure Panner for realistic 3D spatial audio
|
|
106
113
|
const distanceConfig = this.getDistanceConfig();
|
|
107
114
|
panner.panningModel = "HRTF"; // Head-Related Transfer Function for realistic 3D
|
|
@@ -121,15 +128,20 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
121
128
|
}
|
|
122
129
|
currentNode.connect(highpassFilter);
|
|
123
130
|
highpassFilter.connect(lowpassFilter);
|
|
131
|
+
let postFilterNode = lowpassFilter;
|
|
132
|
+
if (this.noiseGateEnabled) {
|
|
133
|
+
lowpassFilter.connect(noiseGate);
|
|
134
|
+
postFilterNode = noiseGate;
|
|
135
|
+
}
|
|
124
136
|
if (bypassSpatialization) {
|
|
125
137
|
console.log(`π TESTING: Connecting audio directly to destination (bypassing spatial audio) for ${participantId}`);
|
|
126
|
-
|
|
138
|
+
postFilterNode.connect(analyser);
|
|
127
139
|
analyser.connect(this.masterGainNode);
|
|
128
140
|
}
|
|
129
141
|
else {
|
|
130
142
|
// Standard spatialized path with full audio chain
|
|
131
|
-
// Audio Chain: source -> filters -> panner -> analyser -> gain -> masterGain -> compressor -> destination
|
|
132
|
-
|
|
143
|
+
// Audio Chain: source -> filters -> (optional gate) -> panner -> analyser -> gain -> masterGain -> compressor -> destination
|
|
144
|
+
postFilterNode.connect(panner);
|
|
133
145
|
panner.connect(analyser);
|
|
134
146
|
analyser.connect(gain);
|
|
135
147
|
gain.connect(this.masterGainNode);
|
|
@@ -139,11 +151,21 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
139
151
|
panner,
|
|
140
152
|
analyser,
|
|
141
153
|
gain,
|
|
154
|
+
noiseGate,
|
|
142
155
|
highpassFilter,
|
|
143
156
|
lowpassFilter,
|
|
144
157
|
denoiseNode,
|
|
145
158
|
stream,
|
|
146
159
|
});
|
|
160
|
+
this.stabilityState.set(participantId, {
|
|
161
|
+
smoothedLevel: 0,
|
|
162
|
+
targetGain: 1,
|
|
163
|
+
networkMuted: false,
|
|
164
|
+
});
|
|
165
|
+
if (this.noiseGateEnabled && typeof track.onmute !== "undefined") {
|
|
166
|
+
track.onmute = () => this.handleTrackStability(participantId, true);
|
|
167
|
+
track.onunmute = () => this.handleTrackStability(participantId, false);
|
|
168
|
+
}
|
|
147
169
|
console.log(`π§ Spatial audio setup complete for ${participantId}:`, {
|
|
148
170
|
audioContextState: this.audioContext.state,
|
|
149
171
|
sampleRate: this.audioContext.sampleRate,
|
|
@@ -160,15 +182,146 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
160
182
|
rolloffFactor: panner.rolloffFactor,
|
|
161
183
|
},
|
|
162
184
|
});
|
|
163
|
-
// Start monitoring audio levels
|
|
164
|
-
this.
|
|
185
|
+
// Start monitoring audio levels if gate enabled
|
|
186
|
+
if (this.noiseGateEnabled) {
|
|
187
|
+
this.startMonitoring(participantId);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async enhanceOutgoingAudioTrack(track) {
|
|
191
|
+
if (track.kind !== "audio" || !this.outboundTuningEnabled) {
|
|
192
|
+
return track;
|
|
193
|
+
}
|
|
194
|
+
const existingProcessor = Array.from(this.outgoingProcessors.values()).find((processor) => processor.originalTrack === track);
|
|
195
|
+
if (existingProcessor) {
|
|
196
|
+
return existingProcessor.processedTrack;
|
|
197
|
+
}
|
|
198
|
+
await this.applyHardwareNoiseConstraints(track);
|
|
199
|
+
const context = new AudioContext({ sampleRate: 48000 });
|
|
200
|
+
await context.resume();
|
|
201
|
+
const sourceStream = new MediaStream([track]);
|
|
202
|
+
const source = context.createMediaStreamSource(sourceStream);
|
|
203
|
+
let current = source;
|
|
204
|
+
let denoiseNode;
|
|
205
|
+
if (this.isDenoiserEnabled() && typeof context.audioWorklet !== "undefined") {
|
|
206
|
+
try {
|
|
207
|
+
await this.ensureDenoiseWorklet(context);
|
|
208
|
+
denoiseNode = new AudioWorkletNode(context, "odyssey-denoise", {
|
|
209
|
+
numberOfInputs: 1,
|
|
210
|
+
numberOfOutputs: 1,
|
|
211
|
+
processorOptions: {
|
|
212
|
+
enabled: true,
|
|
213
|
+
threshold: this.options.denoiser?.threshold,
|
|
214
|
+
noiseFloor: this.options.denoiser?.noiseFloor,
|
|
215
|
+
release: this.options.denoiser?.release,
|
|
216
|
+
wasmBytes: this.denoiserWasmBytes
|
|
217
|
+
? this.denoiserWasmBytes.slice(0)
|
|
218
|
+
: null,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
current.connect(denoiseNode);
|
|
222
|
+
current = denoiseNode;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
console.warn("β οΈ Outgoing denoiser unavailable, continuing without it.", error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const notch60 = context.createBiquadFilter();
|
|
229
|
+
notch60.type = "notch";
|
|
230
|
+
notch60.frequency.value = 60;
|
|
231
|
+
notch60.Q.value = 24;
|
|
232
|
+
current.connect(notch60);
|
|
233
|
+
current = notch60;
|
|
234
|
+
const notch50 = context.createBiquadFilter();
|
|
235
|
+
notch50.type = "notch";
|
|
236
|
+
notch50.frequency.value = 50;
|
|
237
|
+
notch50.Q.value = 24;
|
|
238
|
+
current.connect(notch50);
|
|
239
|
+
current = notch50;
|
|
240
|
+
const lowShelf = context.createBiquadFilter();
|
|
241
|
+
lowShelf.type = "lowshelf";
|
|
242
|
+
lowShelf.frequency.value = 120;
|
|
243
|
+
lowShelf.gain.value = -3;
|
|
244
|
+
current.connect(lowShelf);
|
|
245
|
+
current = lowShelf;
|
|
246
|
+
const highpassFilter = context.createBiquadFilter();
|
|
247
|
+
highpassFilter.type = "highpass";
|
|
248
|
+
highpassFilter.frequency.value = 95;
|
|
249
|
+
highpassFilter.Q.value = 0.8;
|
|
250
|
+
current.connect(highpassFilter);
|
|
251
|
+
current = highpassFilter;
|
|
252
|
+
const lowpassFilter = context.createBiquadFilter();
|
|
253
|
+
lowpassFilter.type = "lowpass";
|
|
254
|
+
lowpassFilter.frequency.value = 7200;
|
|
255
|
+
lowpassFilter.Q.value = 0.8;
|
|
256
|
+
current.connect(lowpassFilter);
|
|
257
|
+
current = lowpassFilter;
|
|
258
|
+
const hissShelf = context.createBiquadFilter();
|
|
259
|
+
hissShelf.type = "highshelf";
|
|
260
|
+
hissShelf.frequency.value = 6400;
|
|
261
|
+
hissShelf.gain.value = -4;
|
|
262
|
+
current.connect(hissShelf);
|
|
263
|
+
current = hissShelf;
|
|
264
|
+
const presenceBoost = context.createBiquadFilter();
|
|
265
|
+
presenceBoost.type = "peaking";
|
|
266
|
+
presenceBoost.frequency.value = 2400;
|
|
267
|
+
presenceBoost.Q.value = 1.1;
|
|
268
|
+
presenceBoost.gain.value = 2.4;
|
|
269
|
+
current.connect(presenceBoost);
|
|
270
|
+
current = presenceBoost;
|
|
271
|
+
const compressor = context.createDynamicsCompressor();
|
|
272
|
+
compressor.threshold.value = -18;
|
|
273
|
+
compressor.knee.value = 16;
|
|
274
|
+
compressor.ratio.value = 3.2;
|
|
275
|
+
compressor.attack.value = 0.002;
|
|
276
|
+
compressor.release.value = 0.22;
|
|
277
|
+
current.connect(compressor);
|
|
278
|
+
current = compressor;
|
|
279
|
+
const postCompressorTap = context.createGain();
|
|
280
|
+
postCompressorTap.gain.value = 1.05;
|
|
281
|
+
current.connect(postCompressorTap);
|
|
282
|
+
current = postCompressorTap;
|
|
283
|
+
const analyser = context.createAnalyser();
|
|
284
|
+
analyser.fftSize = 512;
|
|
285
|
+
current.connect(analyser);
|
|
286
|
+
const gate = context.createGain();
|
|
287
|
+
gate.gain.value = 1;
|
|
288
|
+
current.connect(gate);
|
|
289
|
+
const destination = context.createMediaStreamDestination();
|
|
290
|
+
gate.connect(destination);
|
|
291
|
+
const processedTrack = destination.stream.getAudioTracks()[0];
|
|
292
|
+
processedTrack.contentHint = "speech";
|
|
293
|
+
const processorId = processedTrack.id;
|
|
294
|
+
const monitor = this.startOutboundMonitor(processorId, analyser, gate);
|
|
295
|
+
const cleanup = () => this.cleanupOutboundProcessor(processorId);
|
|
296
|
+
processedTrack.addEventListener("ended", cleanup);
|
|
297
|
+
track.addEventListener("ended", cleanup);
|
|
298
|
+
this.outgoingProcessors.set(processorId, {
|
|
299
|
+
context,
|
|
300
|
+
sourceStream,
|
|
301
|
+
destinationStream: destination.stream,
|
|
302
|
+
analyser,
|
|
303
|
+
gate,
|
|
304
|
+
monitor,
|
|
305
|
+
originalTrack: track,
|
|
306
|
+
processedTrack,
|
|
307
|
+
cleanupListener: cleanup,
|
|
308
|
+
});
|
|
309
|
+
console.log("ποΈ [SDK] Outgoing audio tuned", {
|
|
310
|
+
originalTrackId: track.id,
|
|
311
|
+
processedTrackId: processedTrack.id,
|
|
312
|
+
});
|
|
313
|
+
return processedTrack;
|
|
165
314
|
}
|
|
166
315
|
startMonitoring(participantId) {
|
|
316
|
+
if (!this.noiseGateEnabled) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
167
319
|
const nodes = this.participantNodes.get(participantId);
|
|
168
320
|
if (!nodes)
|
|
169
321
|
return;
|
|
170
|
-
const { analyser, stream } = nodes;
|
|
322
|
+
const { analyser, stream, noiseGate } = nodes;
|
|
171
323
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
324
|
+
let lastTrackLog = 0;
|
|
172
325
|
// Clear any existing interval for this participant
|
|
173
326
|
if (this.monitoringIntervals.has(participantId)) {
|
|
174
327
|
clearInterval(this.monitoringIntervals.get(participantId));
|
|
@@ -181,16 +334,47 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
181
334
|
}
|
|
182
335
|
const average = sum / dataArray.length;
|
|
183
336
|
const audioLevel = (average / 128) * 255; // Scale to 0-255
|
|
184
|
-
|
|
185
|
-
|
|
337
|
+
const normalizedLevel = audioLevel / 255;
|
|
338
|
+
const stability = this.stabilityState.get(participantId);
|
|
339
|
+
if (stability) {
|
|
340
|
+
const smoothing = 0.2;
|
|
341
|
+
stability.smoothedLevel =
|
|
342
|
+
stability.smoothedLevel * (1 - smoothing) + normalizedLevel * smoothing;
|
|
343
|
+
const gateOpenThreshold = 0.035; // empirical speech/noise split
|
|
344
|
+
const gateCloseThreshold = 0.015;
|
|
345
|
+
let targetGain = stability.targetGain;
|
|
346
|
+
if (stability.networkMuted) {
|
|
347
|
+
targetGain = 0;
|
|
348
|
+
}
|
|
349
|
+
else if (stability.smoothedLevel < gateCloseThreshold) {
|
|
350
|
+
targetGain = 0;
|
|
351
|
+
}
|
|
352
|
+
else if (stability.smoothedLevel < gateOpenThreshold) {
|
|
353
|
+
targetGain = 0.35;
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
targetGain = 1;
|
|
357
|
+
}
|
|
358
|
+
if (Math.abs(targetGain - stability.targetGain) > 0.05) {
|
|
359
|
+
const ramp = targetGain > stability.targetGain ? 0.03 : 0.12;
|
|
360
|
+
noiseGate.gain.setTargetAtTime(targetGain, this.audioContext.currentTime, ramp);
|
|
361
|
+
stability.targetGain = targetGain;
|
|
362
|
+
}
|
|
363
|
+
if (Math.random() < 0.05) {
|
|
364
|
+
console.log(`ποΈ [NoiseGate] ${participantId}`, {
|
|
365
|
+
level: stability.smoothedLevel.toFixed(3),
|
|
366
|
+
gain: stability.targetGain.toFixed(2),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (audioLevel < 1.0 && Math.random() < 0.2) {
|
|
186
371
|
console.warn(`β οΈ NO AUDIO DATA detected for ${participantId}! Track may be silent or not transmitting.`);
|
|
187
|
-
console.info(`π‘ Check: 1) Is microphone unmuted? 2) Is correct mic selected? 3) Is mic working in system settings?`);
|
|
188
372
|
}
|
|
189
|
-
|
|
190
|
-
|
|
373
|
+
if (Date.now() - lastTrackLog > 2000) {
|
|
374
|
+
lastTrackLog = Date.now();
|
|
191
375
|
const track = stream.getAudioTracks()[0];
|
|
192
376
|
if (track) {
|
|
193
|
-
console.log(`π Audio track status
|
|
377
|
+
console.log(`π Audio track status for ${participantId}:`, {
|
|
194
378
|
trackEnabled: track.enabled,
|
|
195
379
|
trackMuted: track.muted,
|
|
196
380
|
trackReadyState: track.readyState,
|
|
@@ -202,10 +386,23 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
202
386
|
},
|
|
203
387
|
});
|
|
204
388
|
}
|
|
205
|
-
}
|
|
206
|
-
},
|
|
389
|
+
}
|
|
390
|
+
}, 250); // Adaptive monitoring ~4x per second
|
|
207
391
|
this.monitoringIntervals.set(participantId, interval);
|
|
208
392
|
}
|
|
393
|
+
handleTrackStability(participantId, muted) {
|
|
394
|
+
if (!this.noiseGateEnabled) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const nodes = this.participantNodes.get(participantId);
|
|
398
|
+
if (!nodes)
|
|
399
|
+
return;
|
|
400
|
+
const stability = this.stabilityState.get(participantId);
|
|
401
|
+
if (stability) {
|
|
402
|
+
stability.networkMuted = muted;
|
|
403
|
+
}
|
|
404
|
+
nodes.noiseGate.gain.setTargetAtTime(muted ? 0 : 1, this.audioContext.currentTime, muted ? 0.05 : 0.2);
|
|
405
|
+
}
|
|
209
406
|
/**
|
|
210
407
|
* Update spatial audio position and orientation for a participant
|
|
211
408
|
*
|
|
@@ -389,11 +586,18 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
389
586
|
nodes.panner.disconnect();
|
|
390
587
|
nodes.analyser.disconnect();
|
|
391
588
|
nodes.gain.disconnect();
|
|
589
|
+
nodes.noiseGate.disconnect();
|
|
392
590
|
if (nodes.denoiseNode) {
|
|
393
591
|
nodes.denoiseNode.disconnect();
|
|
394
592
|
}
|
|
593
|
+
const track = nodes.stream.getAudioTracks()[0];
|
|
594
|
+
if (track) {
|
|
595
|
+
track.onmute = null;
|
|
596
|
+
track.onunmute = null;
|
|
597
|
+
}
|
|
395
598
|
nodes.stream.getTracks().forEach((track) => track.stop());
|
|
396
599
|
this.participantNodes.delete(participantId);
|
|
600
|
+
this.stabilityState.delete(participantId);
|
|
397
601
|
console.log(`ποΈ Removed participant ${participantId} from spatial audio.`);
|
|
398
602
|
}
|
|
399
603
|
}
|
|
@@ -476,11 +680,79 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
476
680
|
isDenoiserEnabled() {
|
|
477
681
|
return this.options.denoiser?.enabled !== false;
|
|
478
682
|
}
|
|
479
|
-
async
|
|
683
|
+
async applyHardwareNoiseConstraints(track) {
|
|
684
|
+
try {
|
|
685
|
+
await track.applyConstraints({
|
|
686
|
+
echoCancellation: true,
|
|
687
|
+
noiseSuppression: true,
|
|
688
|
+
autoGainControl: true,
|
|
689
|
+
advanced: [
|
|
690
|
+
{
|
|
691
|
+
echoCancellation: true,
|
|
692
|
+
noiseSuppression: true,
|
|
693
|
+
autoGainControl: true,
|
|
694
|
+
googEchoCancellation: true,
|
|
695
|
+
googNoiseSuppression: true,
|
|
696
|
+
googAutoGainControl: true,
|
|
697
|
+
googHighpassFilter: true,
|
|
698
|
+
googTypingNoiseDetection: true,
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
console.warn("β οΈ Unable to apply hardware audio constraints", error);
|
|
705
|
+
}
|
|
706
|
+
track.contentHint = "speech";
|
|
707
|
+
}
|
|
708
|
+
startOutboundMonitor(processorId, analyser, gate) {
|
|
709
|
+
const dataArray = new Uint8Array(analyser.fftSize);
|
|
710
|
+
let smoothedLevel = 0;
|
|
711
|
+
return setInterval(() => {
|
|
712
|
+
analyser.getByteTimeDomainData(dataArray);
|
|
713
|
+
let sum = 0;
|
|
714
|
+
for (const value of dataArray) {
|
|
715
|
+
sum += Math.abs(value - 128);
|
|
716
|
+
}
|
|
717
|
+
const level = (sum / dataArray.length) / 128;
|
|
718
|
+
smoothedLevel = smoothedLevel * 0.7 + level * 0.3;
|
|
719
|
+
let targetGain = 1;
|
|
720
|
+
if (smoothedLevel < 0.02) {
|
|
721
|
+
targetGain = 0;
|
|
722
|
+
}
|
|
723
|
+
else if (smoothedLevel < 0.05) {
|
|
724
|
+
targetGain = 0.45;
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
targetGain = 1;
|
|
728
|
+
}
|
|
729
|
+
gate.gain.setTargetAtTime(targetGain, gate.context.currentTime, targetGain > gate.gain.value ? 0.02 : 0.08);
|
|
730
|
+
if (Math.random() < 0.03) {
|
|
731
|
+
console.log("ποΈ [SDK] Outgoing gate", {
|
|
732
|
+
processorId,
|
|
733
|
+
level: smoothedLevel.toFixed(3),
|
|
734
|
+
gain: targetGain.toFixed(2),
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}, 200);
|
|
738
|
+
}
|
|
739
|
+
cleanupOutboundProcessor(processorId) {
|
|
740
|
+
const processor = this.outgoingProcessors.get(processorId);
|
|
741
|
+
if (!processor)
|
|
742
|
+
return;
|
|
743
|
+
clearInterval(processor.monitor);
|
|
744
|
+
processor.processedTrack.removeEventListener("ended", processor.cleanupListener);
|
|
745
|
+
processor.originalTrack.removeEventListener("ended", processor.cleanupListener);
|
|
746
|
+
processor.destinationStream.getTracks().forEach((t) => t.stop());
|
|
747
|
+
processor.sourceStream.getTracks().forEach((t) => t.stop());
|
|
748
|
+
processor.context.close();
|
|
749
|
+
this.outgoingProcessors.delete(processorId);
|
|
750
|
+
}
|
|
751
|
+
async ensureDenoiseWorklet(targetContext = this.audioContext) {
|
|
480
752
|
if (!this.isDenoiserEnabled()) {
|
|
481
753
|
return;
|
|
482
754
|
}
|
|
483
|
-
if (!("audioWorklet" in
|
|
755
|
+
if (!("audioWorklet" in targetContext)) {
|
|
484
756
|
console.warn("β οΈ AudioWorklet not supported in this browser. Disabling denoiser.");
|
|
485
757
|
this.options.denoiser = {
|
|
486
758
|
...(this.options.denoiser || {}),
|
|
@@ -488,8 +760,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
488
760
|
};
|
|
489
761
|
return;
|
|
490
762
|
}
|
|
491
|
-
|
|
492
|
-
|
|
763
|
+
const existingPromise = this.denoiseContextPromises.get(targetContext);
|
|
764
|
+
if (existingPromise) {
|
|
765
|
+
return existingPromise;
|
|
493
766
|
}
|
|
494
767
|
const processorSource = `class OdysseyDenoiseProcessor extends AudioWorkletProcessor {
|
|
495
768
|
constructor(options) {
|
|
@@ -546,11 +819,13 @@ class SpatialAudioManager extends EventManager_1.EventManager {
|
|
|
546
819
|
|
|
547
820
|
registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
|
|
548
821
|
`;
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
822
|
+
if (!this.denoiseWorkletUrl) {
|
|
823
|
+
const blob = new Blob([processorSource], {
|
|
824
|
+
type: "application/javascript",
|
|
825
|
+
});
|
|
826
|
+
this.denoiseWorkletUrl = URL.createObjectURL(blob);
|
|
827
|
+
}
|
|
828
|
+
const promise = targetContext.audioWorklet
|
|
554
829
|
.addModule(this.denoiseWorkletUrl)
|
|
555
830
|
.catch((error) => {
|
|
556
831
|
console.error("β Failed to register denoise worklet", error);
|
|
@@ -560,7 +835,8 @@ registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
|
|
|
560
835
|
};
|
|
561
836
|
throw error;
|
|
562
837
|
});
|
|
563
|
-
|
|
838
|
+
this.denoiseContextPromises.set(targetContext, promise);
|
|
839
|
+
return promise;
|
|
564
840
|
}
|
|
565
841
|
resolveOptions(options) {
|
|
566
842
|
const distanceDefaults = {
|
|
@@ -575,6 +851,12 @@ registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
|
|
|
575
851
|
noiseFloor: 0.004,
|
|
576
852
|
release: 0.18,
|
|
577
853
|
};
|
|
854
|
+
const noiseGateDefaults = {
|
|
855
|
+
enabled: false,
|
|
856
|
+
};
|
|
857
|
+
const outboundDefaults = {
|
|
858
|
+
enabled: false,
|
|
859
|
+
};
|
|
578
860
|
return {
|
|
579
861
|
distance: {
|
|
580
862
|
refDistance: options?.distance?.refDistance ?? distanceDefaults.refDistance,
|
|
@@ -588,6 +870,12 @@ registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
|
|
|
588
870
|
noiseFloor: options?.denoiser?.noiseFloor ?? denoiserDefaults.noiseFloor,
|
|
589
871
|
release: options?.denoiser?.release ?? denoiserDefaults.release,
|
|
590
872
|
},
|
|
873
|
+
noiseGate: {
|
|
874
|
+
enabled: options?.noiseGate?.enabled ?? noiseGateDefaults.enabled,
|
|
875
|
+
},
|
|
876
|
+
outboundTuning: {
|
|
877
|
+
enabled: options?.outboundTuning?.enabled ?? outboundDefaults.enabled,
|
|
878
|
+
},
|
|
591
879
|
};
|
|
592
880
|
}
|
|
593
881
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export declare class OdysseySpatialComms extends EventManager {
|
|
|
26
26
|
}): Promise<Participant>;
|
|
27
27
|
leaveRoom(): void;
|
|
28
28
|
resumeAudio(): Promise<void>;
|
|
29
|
+
enhanceOutgoingAudioTrack(track: MediaStreamTrack): Promise<MediaStreamTrack>;
|
|
29
30
|
getAudioContextState(): AudioContextState;
|
|
30
31
|
produceTrack(track: MediaStreamTrack): Promise<any>;
|
|
31
32
|
updatePosition(position: Position, direction: Direction, spatialData?: {
|
package/dist/index.js
CHANGED
|
@@ -121,6 +121,9 @@ class OdysseySpatialComms extends EventManager_1.EventManager {
|
|
|
121
121
|
async resumeAudio() {
|
|
122
122
|
await this.spatialAudioManager.resumeAudioContext();
|
|
123
123
|
}
|
|
124
|
+
async enhanceOutgoingAudioTrack(track) {
|
|
125
|
+
return this.spatialAudioManager.enhanceOutgoingAudioTrack(track);
|
|
126
|
+
}
|
|
124
127
|
getAudioContextState() {
|
|
125
128
|
return this.spatialAudioManager.getAudioContextState();
|
|
126
129
|
}
|
package/package.json
CHANGED