@newgameplusinc/odyssey-audio-video-sdk-dev 1.0.8 → 1.0.10

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.
@@ -1,13 +1,33 @@
1
1
  import { EventManager } from "./EventManager";
2
2
  import { Position } from "./types";
3
+ type SpatialAudioDistanceConfig = {
4
+ refDistance?: number;
5
+ maxDistance?: number;
6
+ rolloffFactor?: number;
7
+ unit?: "auto" | "meters" | "centimeters";
8
+ };
9
+ type DenoiserOptions = {
10
+ enabled?: boolean;
11
+ threshold?: number;
12
+ noiseFloor?: number;
13
+ release?: number;
14
+ };
15
+ type SpatialAudioOptions = {
16
+ distance?: SpatialAudioDistanceConfig;
17
+ denoiser?: DenoiserOptions;
18
+ };
3
19
  export declare class SpatialAudioManager extends EventManager {
4
20
  private audioContext;
5
21
  private participantNodes;
6
22
  private masterGainNode;
7
23
  private monitoringIntervals;
8
24
  private compressor;
25
+ private options;
26
+ private denoiseWorkletReady;
27
+ private denoiseWorkletUrl?;
28
+ private denoiserWasmBytes?;
9
29
  private listenerDirection;
10
- constructor();
30
+ constructor(options?: SpatialAudioOptions);
11
31
  getAudioContext(): AudioContext;
12
32
  /**
13
33
  * Setup spatial audio for a participant
@@ -24,7 +44,7 @@ export declare class SpatialAudioManager extends EventManager {
24
44
  * @param track Audio track from MediaSoup consumer
25
45
  * @param bypassSpatialization For testing - bypasses 3D positioning
26
46
  */
27
- setupSpatialAudioForParticipant(participantId: string, track: MediaStreamTrack, bypassSpatialization?: boolean): void;
47
+ setupSpatialAudioForParticipant(participantId: string, track: MediaStreamTrack, bypassSpatialization?: boolean): Promise<void>;
28
48
  private startMonitoring;
29
49
  /**
30
50
  * Update spatial audio position and orientation for a participant
@@ -68,4 +88,10 @@ export declare class SpatialAudioManager extends EventManager {
68
88
  removeParticipant(participantId: string): void;
69
89
  resumeAudioContext(): Promise<void>;
70
90
  getAudioContextState(): AudioContextState;
91
+ private getDistanceConfig;
92
+ private normalizePositionUnits;
93
+ private isDenoiserEnabled;
94
+ private ensureDenoiseWorklet;
95
+ private resolveOptions;
71
96
  }
97
+ export {};
@@ -3,14 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SpatialAudioManager = void 0;
4
4
  const EventManager_1 = require("./EventManager");
5
5
  class SpatialAudioManager extends EventManager_1.EventManager {
6
- constructor() {
6
+ constructor(options) {
7
7
  super();
8
8
  this.participantNodes = new Map();
9
9
  this.monitoringIntervals = new Map();
10
+ this.denoiseWorkletReady = null;
10
11
  this.listenerDirection = {
11
12
  forward: { x: 0, y: 1, z: 0 },
12
13
  up: { x: 0, y: 0, z: 1 },
13
14
  };
15
+ this.options = this.resolveOptions(options);
14
16
  // Use high sample rate for best audio quality
15
17
  this.audioContext = new AudioContext({ sampleRate: 48000 });
16
18
  // Master gain
@@ -54,10 +56,10 @@ class SpatialAudioManager extends EventManager_1.EventManager {
54
56
  * @param track Audio track from MediaSoup consumer
55
57
  * @param bypassSpatialization For testing - bypasses 3D positioning
56
58
  */
57
- setupSpatialAudioForParticipant(participantId, track, bypassSpatialization = false // Default to false
59
+ async setupSpatialAudioForParticipant(participantId, track, bypassSpatialization = false // Default to false
58
60
  ) {
59
61
  if (this.audioContext.state === "suspended") {
60
- this.audioContext.resume();
62
+ await this.audioContext.resume();
61
63
  }
62
64
  // Create stream with noise suppression constraints
63
65
  const stream = new MediaStream([track]);
@@ -65,25 +67,67 @@ class SpatialAudioManager extends EventManager_1.EventManager {
65
67
  const panner = this.audioContext.createPanner();
66
68
  const analyser = this.audioContext.createAnalyser();
67
69
  const gain = this.audioContext.createGain();
70
+ let denoiseNode;
71
+ if (this.isDenoiserEnabled() && typeof this.audioContext.audioWorklet !== "undefined") {
72
+ try {
73
+ await this.ensureDenoiseWorklet();
74
+ denoiseNode = new AudioWorkletNode(this.audioContext, "odyssey-denoise", {
75
+ numberOfInputs: 1,
76
+ numberOfOutputs: 1,
77
+ processorOptions: {
78
+ enabled: this.options.denoiser?.enabled !== false,
79
+ threshold: this.options.denoiser?.threshold,
80
+ noiseFloor: this.options.denoiser?.noiseFloor,
81
+ release: this.options.denoiser?.release,
82
+ wasmBytes: this.denoiserWasmBytes
83
+ ? this.denoiserWasmBytes.slice(0)
84
+ : null,
85
+ },
86
+ });
87
+ }
88
+ catch (error) {
89
+ console.warn("⚠️ Failed to initialize denoiser worklet. Falling back to raw audio.", error);
90
+ denoiseNode = undefined;
91
+ }
92
+ }
93
+ // Create BiquadFilter nodes for static/noise reduction
94
+ // Based on: https://tagdiwalaviral.medium.com/struggles-of-noise-reduction-in-rtc-part-2-2526f8179442
95
+ const highpassFilter = this.audioContext.createBiquadFilter();
96
+ highpassFilter.type = "highpass";
97
+ highpassFilter.frequency.value = 85; // Conservative value to preserve male voice depth
98
+ highpassFilter.Q.value = 1.0; // Quality factor
99
+ const lowpassFilter = this.audioContext.createBiquadFilter();
100
+ lowpassFilter.type = "lowpass";
101
+ lowpassFilter.frequency.value = 7500; // Below 8kHz to avoid flat/muffled sound
102
+ lowpassFilter.Q.value = 1.0; // Quality factor
68
103
  // Configure Panner for realistic 3D spatial audio
104
+ const distanceConfig = this.getDistanceConfig();
69
105
  panner.panningModel = "HRTF"; // Head-Related Transfer Function for realistic 3D
70
106
  panner.distanceModel = "inverse"; // Natural distance falloff
71
- panner.refDistance = 100; // Distance at which volume = 1.0 (1 meter in Unreal = 100cm)
72
- panner.maxDistance = 1500; // Maximum audible distance (15 meters)
73
- panner.rolloffFactor = 1.5; // How quickly sound fades with distance
107
+ panner.refDistance = distanceConfig.refDistance ?? 1.2;
108
+ panner.maxDistance = distanceConfig.maxDistance ?? 30;
109
+ panner.rolloffFactor = distanceConfig.rolloffFactor ?? 1.35; // How quickly sound fades with distance
74
110
  panner.coneInnerAngle = 360; // Omnidirectional sound source
75
111
  panner.coneOuterAngle = 360;
76
112
  panner.coneOuterGain = 0.3; // Some sound even outside cone
77
113
  // Configure gain for individual participant volume control
78
114
  gain.gain.value = 1.0;
115
+ let currentNode = source;
116
+ if (denoiseNode) {
117
+ currentNode.connect(denoiseNode);
118
+ currentNode = denoiseNode;
119
+ }
120
+ currentNode.connect(highpassFilter);
121
+ highpassFilter.connect(lowpassFilter);
79
122
  if (bypassSpatialization) {
80
123
  console.log(`🔊 TESTING: Connecting audio directly to destination (bypassing spatial audio) for ${participantId}`);
81
- source.connect(analyser);
124
+ lowpassFilter.connect(analyser);
82
125
  analyser.connect(this.masterGainNode);
83
126
  }
84
127
  else {
85
128
  // Standard spatialized path with full audio chain
86
- source.connect(panner);
129
+ // Audio Chain: source -> filters -> panner -> analyser -> gain -> masterGain -> compressor -> destination
130
+ lowpassFilter.connect(panner);
87
131
  panner.connect(analyser);
88
132
  analyser.connect(gain);
89
133
  gain.connect(this.masterGainNode);
@@ -93,6 +137,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
93
137
  panner,
94
138
  analyser,
95
139
  gain,
140
+ highpassFilter,
141
+ lowpassFilter,
142
+ denoiseNode,
96
143
  stream,
97
144
  });
98
145
  console.log(`🎧 Spatial audio setup complete for ${participantId}:`, {
@@ -180,10 +227,11 @@ class SpatialAudioManager extends EventManager_1.EventManager {
180
227
  updateSpatialAudio(participantId, position, direction) {
181
228
  const nodes = this.participantNodes.get(participantId);
182
229
  if (nodes?.panner) {
230
+ const normalizedPosition = this.normalizePositionUnits(position);
183
231
  // Update position (where the sound is coming from)
184
- nodes.panner.positionX.setValueAtTime(position.x, this.audioContext.currentTime);
185
- nodes.panner.positionY.setValueAtTime(position.y, this.audioContext.currentTime);
186
- nodes.panner.positionZ.setValueAtTime(position.z, this.audioContext.currentTime);
232
+ nodes.panner.positionX.setValueAtTime(normalizedPosition.x, this.audioContext.currentTime);
233
+ nodes.panner.positionY.setValueAtTime(normalizedPosition.y, this.audioContext.currentTime);
234
+ nodes.panner.positionZ.setValueAtTime(normalizedPosition.z, this.audioContext.currentTime);
187
235
  // Update orientation (where the participant is facing)
188
236
  // This makes the audio source directional based on participant's direction
189
237
  if (direction) {
@@ -205,6 +253,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
205
253
  setListenerPosition(position, orientation) {
206
254
  const { listener } = this.audioContext;
207
255
  if (listener) {
256
+ const normalizedPosition = this.normalizePositionUnits(position);
208
257
  // Store listener direction for reference
209
258
  this.listenerDirection = {
210
259
  forward: {
@@ -220,9 +269,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
220
269
  };
221
270
  // Use setPosition and setOrientation for atomic updates if available
222
271
  if (listener.positionX) {
223
- listener.positionX.setValueAtTime(position.x, this.audioContext.currentTime);
224
- listener.positionY.setValueAtTime(position.y, this.audioContext.currentTime);
225
- listener.positionZ.setValueAtTime(position.z, this.audioContext.currentTime);
272
+ listener.positionX.setValueAtTime(normalizedPosition.x, this.audioContext.currentTime);
273
+ listener.positionY.setValueAtTime(normalizedPosition.y, this.audioContext.currentTime);
274
+ listener.positionZ.setValueAtTime(normalizedPosition.z, this.audioContext.currentTime);
226
275
  }
227
276
  if (listener.forwardX) {
228
277
  listener.forwardX.setValueAtTime(orientation.forwardX, this.audioContext.currentTime);
@@ -256,10 +305,13 @@ class SpatialAudioManager extends EventManager_1.EventManager {
256
305
  * @param lookAtPos Look-at position (where camera is pointing)
257
306
  */
258
307
  setListenerFromLSD(listenerPos, cameraPos, lookAtPos) {
308
+ const normalizedListener = this.normalizePositionUnits(listenerPos);
309
+ const normalizedCamera = this.normalizePositionUnits(cameraPos);
310
+ const normalizedLookAt = this.normalizePositionUnits(lookAtPos);
259
311
  // Calculate forward vector (from camera to look-at point)
260
- const forwardX = lookAtPos.x - cameraPos.x;
261
- const forwardY = lookAtPos.y - cameraPos.y;
262
- const forwardZ = lookAtPos.z - cameraPos.z;
312
+ const forwardX = normalizedLookAt.x - normalizedCamera.x;
313
+ const forwardY = normalizedLookAt.y - normalizedCamera.y;
314
+ const forwardZ = normalizedLookAt.z - normalizedCamera.z;
263
315
  // Normalize forward vector
264
316
  const forwardLen = Math.sqrt(forwardX * forwardX + forwardY * forwardY + forwardZ * forwardZ);
265
317
  if (forwardLen < 0.001) {
@@ -277,7 +329,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
277
329
  const rightLen = Math.sqrt(rightX * rightX + rightY * rightY + rightZ * rightZ);
278
330
  if (rightLen < 0.001) {
279
331
  // Forward is parallel to world up, use fallback
280
- this.setListenerPosition(listenerPos, {
332
+ this.setListenerPosition(normalizedListener, {
281
333
  forwardX: fwdX,
282
334
  forwardY: fwdY,
283
335
  forwardZ: fwdZ,
@@ -294,7 +346,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
294
346
  const upX = fwdY * rZ - fwdZ * rY;
295
347
  const upY = fwdZ * rX - fwdX * rZ;
296
348
  const upZ = fwdX * rY - fwdY * rX;
297
- this.setListenerPosition(listenerPos, {
349
+ this.setListenerPosition(normalizedListener, {
298
350
  forwardX: fwdX,
299
351
  forwardY: fwdY,
300
352
  forwardZ: fwdZ,
@@ -315,6 +367,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
315
367
  nodes.panner.disconnect();
316
368
  nodes.analyser.disconnect();
317
369
  nodes.gain.disconnect();
370
+ if (nodes.denoiseNode) {
371
+ nodes.denoiseNode.disconnect();
372
+ }
318
373
  nodes.stream.getTracks().forEach((track) => track.stop());
319
374
  this.participantNodes.delete(participantId);
320
375
  console.log(`🗑️ Removed participant ${participantId} from spatial audio.`);
@@ -329,5 +384,153 @@ class SpatialAudioManager extends EventManager_1.EventManager {
329
384
  getAudioContextState() {
330
385
  return this.audioContext.state;
331
386
  }
387
+ getDistanceConfig() {
388
+ return {
389
+ refDistance: this.options.distance?.refDistance ?? 1.2,
390
+ maxDistance: this.options.distance?.maxDistance ?? 30,
391
+ rolloffFactor: this.options.distance?.rolloffFactor ?? 1.35,
392
+ unit: this.options.distance?.unit ?? "auto",
393
+ };
394
+ }
395
+ normalizePositionUnits(position) {
396
+ const distanceConfig = this.getDistanceConfig();
397
+ if (distanceConfig.unit === "meters") {
398
+ return { ...position };
399
+ }
400
+ if (distanceConfig.unit === "centimeters") {
401
+ return {
402
+ x: position.x / 100,
403
+ y: position.y / 100,
404
+ z: position.z / 100,
405
+ };
406
+ }
407
+ const maxAxis = Math.max(Math.abs(position.x), Math.abs(position.y), Math.abs(position.z));
408
+ if (maxAxis > 50) {
409
+ // Likely centimeters coming from server
410
+ return {
411
+ x: position.x / 100,
412
+ y: position.y / 100,
413
+ z: position.z / 100,
414
+ };
415
+ }
416
+ return { ...position };
417
+ }
418
+ isDenoiserEnabled() {
419
+ return this.options.denoiser?.enabled !== false;
420
+ }
421
+ async ensureDenoiseWorklet() {
422
+ if (!this.isDenoiserEnabled()) {
423
+ return;
424
+ }
425
+ if (!("audioWorklet" in this.audioContext)) {
426
+ console.warn("⚠️ AudioWorklet not supported in this browser. Disabling denoiser.");
427
+ this.options.denoiser = {
428
+ ...(this.options.denoiser || {}),
429
+ enabled: false,
430
+ };
431
+ return;
432
+ }
433
+ if (this.denoiseWorkletReady) {
434
+ return this.denoiseWorkletReady;
435
+ }
436
+ const processorSource = `class OdysseyDenoiseProcessor extends AudioWorkletProcessor {
437
+ constructor(options) {
438
+ super();
439
+ const cfg = (options && options.processorOptions) || {};
440
+ this.enabled = cfg.enabled !== false;
441
+ this.threshold = typeof cfg.threshold === 'number' ? cfg.threshold : 0.012;
442
+ this.noiseFloor = typeof cfg.noiseFloor === 'number' ? cfg.noiseFloor : 0.004;
443
+ this.release = typeof cfg.release === 'number' ? cfg.release : 0.18;
444
+ this.smoothedLevel = this.noiseFloor;
445
+ }
446
+
447
+ process(inputs, outputs) {
448
+ const input = inputs[0];
449
+ const output = outputs[0];
450
+ if (!input || input.length === 0 || !output || output.length === 0) {
451
+ return true;
452
+ }
453
+
454
+ for (let channel = 0; channel < input.length; channel++) {
455
+ const inChannel = input[channel];
456
+ const outChannel = output[channel];
457
+ if (!inChannel || !outChannel) {
458
+ continue;
459
+ }
460
+
461
+ let sum = 0;
462
+ for (let i = 0; i < inChannel.length; i++) {
463
+ const sample = inChannel[i];
464
+ sum += sample * sample;
465
+ }
466
+
467
+ const rms = Math.sqrt(sum / inChannel.length);
468
+ this.smoothedLevel += (rms - this.smoothedLevel) * this.release;
469
+ const dynamicThreshold = Math.max(
470
+ this.noiseFloor,
471
+ this.threshold * 0.6 + this.smoothedLevel * 0.4
472
+ );
473
+
474
+ if (!this.enabled || rms >= dynamicThreshold) {
475
+ for (let i = 0; i < inChannel.length; i++) {
476
+ outChannel[i] = inChannel[i];
477
+ }
478
+ } else {
479
+ for (let i = 0; i < inChannel.length; i++) {
480
+ outChannel[i] = 0;
481
+ }
482
+ }
483
+ }
484
+
485
+ return true;
486
+ }
487
+ }
488
+
489
+ registerProcessor('odyssey-denoise', OdysseyDenoiseProcessor);
490
+ `;
491
+ const blob = new Blob([processorSource], {
492
+ type: "application/javascript",
493
+ });
494
+ this.denoiseWorkletUrl = URL.createObjectURL(blob);
495
+ this.denoiseWorkletReady = this.audioContext.audioWorklet
496
+ .addModule(this.denoiseWorkletUrl)
497
+ .catch((error) => {
498
+ console.error("❌ Failed to register denoise worklet", error);
499
+ this.options.denoiser = {
500
+ ...(this.options.denoiser || {}),
501
+ enabled: false,
502
+ };
503
+ throw error;
504
+ });
505
+ return this.denoiseWorkletReady;
506
+ }
507
+ resolveOptions(options) {
508
+ const distanceDefaults = {
509
+ refDistance: 1.2,
510
+ maxDistance: 30,
511
+ rolloffFactor: 1.35,
512
+ unit: "auto",
513
+ };
514
+ const denoiserDefaults = {
515
+ enabled: true,
516
+ threshold: 0.012,
517
+ noiseFloor: 0.004,
518
+ release: 0.18,
519
+ };
520
+ return {
521
+ distance: {
522
+ refDistance: options?.distance?.refDistance ?? distanceDefaults.refDistance,
523
+ maxDistance: options?.distance?.maxDistance ?? distanceDefaults.maxDistance,
524
+ rolloffFactor: options?.distance?.rolloffFactor ?? distanceDefaults.rolloffFactor,
525
+ unit: options?.distance?.unit ?? distanceDefaults.unit,
526
+ },
527
+ denoiser: {
528
+ enabled: options?.denoiser?.enabled ?? denoiserDefaults.enabled,
529
+ threshold: options?.denoiser?.threshold ?? denoiserDefaults.threshold,
530
+ noiseFloor: options?.denoiser?.noiseFloor ?? denoiserDefaults.noiseFloor,
531
+ release: options?.denoiser?.release ?? denoiserDefaults.release,
532
+ },
533
+ };
534
+ }
332
535
  }
333
536
  exports.SpatialAudioManager = SpatialAudioManager;
package/dist/index.js CHANGED
@@ -425,7 +425,7 @@ class OdysseySpatialComms extends EventManager_1.EventManager {
425
425
  else {
426
426
  console.log("🎧 SDK: Setting up spatial audio for REMOTE participant", participant.participantId);
427
427
  // Setup spatial audio with full 3D positioning
428
- this.spatialAudioManager.setupSpatialAudioForParticipant(participant.participantId, track, false // Enable spatial audio
428
+ await this.spatialAudioManager.setupSpatialAudioForParticipant(participant.participantId, track, false // Enable spatial audio
429
429
  );
430
430
  // Update spatial audio position for this participant
431
431
  this.spatialAudioManager.updateSpatialAudio(participant.participantId, data.position);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newgameplusinc/odyssey-audio-video-sdk-dev",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
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",