@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 CHANGED
@@ -1,106 +1,145 @@
1
- # Odyssey Spatial Audio SDK
2
-
3
- A comprehensive SDK for real-time spatial audio and video communication using MediaSoup, designed for immersive multi-user experiences in the Odyssey platform.
4
-
5
- ## Purpose
6
-
7
- This package provides a complete WebRTC-based spatial audio and video solution that:
8
- - Manages MediaSoup connections for audio/video streaming
9
- - Implements spatial audio using Web Audio API with HRTF
10
- - Handles participant management with user profile data (bodyHeight, bodyShape, email, etc.)
11
- - Provides real-time position tracking for immersive spatial experiences
12
-
13
- ## Installation
14
-
15
- You can install this package from npm:
16
-
17
- ```bash
18
- npm install @newgameplusinc/odyssey-spatial-sdk-wrapper
19
- ```
20
-
21
- Or install locally:
22
-
23
- ```bash
24
- npm install ../mediasoup-sdk-test
25
- ```
26
-
27
- ## Usage
28
-
29
- ### 1. Initialize the SDK
30
-
31
- ```typescript
32
- import { OdysseySpatialComms } from "@newgameplusinc/odyssey-spatial-sdk-wrapper";
33
-
34
- // Initialize with your MediaSoup server URL
35
- const sdk = new OdysseySpatialComms("https://your-mediasoup-server.com");
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
- ### 3. Produce Audio/Video Tracks
55
-
56
- ```typescript
57
- // Get user media
58
- const stream = await navigator.mediaDevices.getUserMedia({
59
- audio: true,
60
- video: true
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
- // Produce audio track
64
- const audioTrack = stream.getAudioTracks()[0];
65
- await sdk.produceTrack(audioTrack);
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
- ### 4. Update Position for Spatial Audio
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
- ### 5. Listen to Events
82
-
83
- ```typescript
84
- // New participant joined
85
- sdk.on("new-participant", (participant) => {
86
- console.log("New participant:", participant.userName, participant.bodyHeight);
87
- });
88
-
89
- // Participant left
90
- sdk.on("participant-left", (participantId) => {
91
- console.log("Participant left:", participantId);
92
- });
93
-
94
- // Consumer created (receiving audio/video from remote participant)
95
- sdk.on("consumer-created", ({ participant, track, consumer }) => {
96
- console.log("Receiving", track.kind, "from", participant.userName);
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
- ## Build
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.denoiseWorkletReady = null;
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
- lowpassFilter.connect(analyser);
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
- lowpassFilter.connect(panner);
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.startMonitoring(participantId);
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
- console.log(`πŸ“Š Audio level for ${participantId}: ${audioLevel.toFixed(2)} (0-255 scale)`);
185
- if (audioLevel < 1.0) {
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
- // Check track status after 2 seconds
190
- setTimeout(() => {
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 after 2s for ${participantId}:`, {
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
- }, 2000);
206
- }, 2000); // Log every 2 seconds
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 ensureDenoiseWorklet() {
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 this.audioContext)) {
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
- if (this.denoiseWorkletReady) {
492
- return this.denoiseWorkletReady;
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
- const blob = new Blob([processorSource], {
550
- type: "application/javascript",
551
- });
552
- this.denoiseWorkletUrl = URL.createObjectURL(blob);
553
- this.denoiseWorkletReady = this.audioContext.audioWorklet
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
- return this.denoiseWorkletReady;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newgameplusinc/odyssey-audio-video-sdk-dev",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
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",