@newgameplusinc/odyssey-audio-video-sdk-dev 1.0.9 → 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,6 +67,29 @@ 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
+ }
68
93
  // Create BiquadFilter nodes for static/noise reduction
69
94
  // Based on: https://tagdiwalaviral.medium.com/struggles-of-noise-reduction-in-rtc-part-2-2526f8179442
70
95
  const highpassFilter = this.audioContext.createBiquadFilter();
@@ -76,28 +101,32 @@ class SpatialAudioManager extends EventManager_1.EventManager {
76
101
  lowpassFilter.frequency.value = 7500; // Below 8kHz to avoid flat/muffled sound
77
102
  lowpassFilter.Q.value = 1.0; // Quality factor
78
103
  // Configure Panner for realistic 3D spatial audio
104
+ const distanceConfig = this.getDistanceConfig();
79
105
  panner.panningModel = "HRTF"; // Head-Related Transfer Function for realistic 3D
80
106
  panner.distanceModel = "inverse"; // Natural distance falloff
81
- panner.refDistance = 100; // Distance at which volume = 1.0 (1 meter in Unreal = 100cm)
82
- panner.maxDistance = 1500; // Maximum audible distance (15 meters)
83
- 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
84
110
  panner.coneInnerAngle = 360; // Omnidirectional sound source
85
111
  panner.coneOuterAngle = 360;
86
112
  panner.coneOuterGain = 0.3; // Some sound even outside cone
87
113
  // Configure gain for individual participant volume control
88
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);
89
122
  if (bypassSpatialization) {
90
123
  console.log(`🔊 TESTING: Connecting audio directly to destination (bypassing spatial audio) for ${participantId}`);
91
- source.connect(highpassFilter);
92
- highpassFilter.connect(lowpassFilter);
93
124
  lowpassFilter.connect(analyser);
94
125
  analyser.connect(this.masterGainNode);
95
126
  }
96
127
  else {
97
128
  // Standard spatialized path with full audio chain
98
129
  // Audio Chain: source -> filters -> panner -> analyser -> gain -> masterGain -> compressor -> destination
99
- source.connect(highpassFilter);
100
- highpassFilter.connect(lowpassFilter);
101
130
  lowpassFilter.connect(panner);
102
131
  panner.connect(analyser);
103
132
  analyser.connect(gain);
@@ -110,6 +139,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
110
139
  gain,
111
140
  highpassFilter,
112
141
  lowpassFilter,
142
+ denoiseNode,
113
143
  stream,
114
144
  });
115
145
  console.log(`🎧 Spatial audio setup complete for ${participantId}:`, {
@@ -197,10 +227,11 @@ class SpatialAudioManager extends EventManager_1.EventManager {
197
227
  updateSpatialAudio(participantId, position, direction) {
198
228
  const nodes = this.participantNodes.get(participantId);
199
229
  if (nodes?.panner) {
230
+ const normalizedPosition = this.normalizePositionUnits(position);
200
231
  // Update position (where the sound is coming from)
201
- nodes.panner.positionX.setValueAtTime(position.x, this.audioContext.currentTime);
202
- nodes.panner.positionY.setValueAtTime(position.y, this.audioContext.currentTime);
203
- 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);
204
235
  // Update orientation (where the participant is facing)
205
236
  // This makes the audio source directional based on participant's direction
206
237
  if (direction) {
@@ -222,6 +253,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
222
253
  setListenerPosition(position, orientation) {
223
254
  const { listener } = this.audioContext;
224
255
  if (listener) {
256
+ const normalizedPosition = this.normalizePositionUnits(position);
225
257
  // Store listener direction for reference
226
258
  this.listenerDirection = {
227
259
  forward: {
@@ -237,9 +269,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
237
269
  };
238
270
  // Use setPosition and setOrientation for atomic updates if available
239
271
  if (listener.positionX) {
240
- listener.positionX.setValueAtTime(position.x, this.audioContext.currentTime);
241
- listener.positionY.setValueAtTime(position.y, this.audioContext.currentTime);
242
- 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);
243
275
  }
244
276
  if (listener.forwardX) {
245
277
  listener.forwardX.setValueAtTime(orientation.forwardX, this.audioContext.currentTime);
@@ -273,10 +305,13 @@ class SpatialAudioManager extends EventManager_1.EventManager {
273
305
  * @param lookAtPos Look-at position (where camera is pointing)
274
306
  */
275
307
  setListenerFromLSD(listenerPos, cameraPos, lookAtPos) {
308
+ const normalizedListener = this.normalizePositionUnits(listenerPos);
309
+ const normalizedCamera = this.normalizePositionUnits(cameraPos);
310
+ const normalizedLookAt = this.normalizePositionUnits(lookAtPos);
276
311
  // Calculate forward vector (from camera to look-at point)
277
- const forwardX = lookAtPos.x - cameraPos.x;
278
- const forwardY = lookAtPos.y - cameraPos.y;
279
- 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;
280
315
  // Normalize forward vector
281
316
  const forwardLen = Math.sqrt(forwardX * forwardX + forwardY * forwardY + forwardZ * forwardZ);
282
317
  if (forwardLen < 0.001) {
@@ -294,7 +329,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
294
329
  const rightLen = Math.sqrt(rightX * rightX + rightY * rightY + rightZ * rightZ);
295
330
  if (rightLen < 0.001) {
296
331
  // Forward is parallel to world up, use fallback
297
- this.setListenerPosition(listenerPos, {
332
+ this.setListenerPosition(normalizedListener, {
298
333
  forwardX: fwdX,
299
334
  forwardY: fwdY,
300
335
  forwardZ: fwdZ,
@@ -311,7 +346,7 @@ class SpatialAudioManager extends EventManager_1.EventManager {
311
346
  const upX = fwdY * rZ - fwdZ * rY;
312
347
  const upY = fwdZ * rX - fwdX * rZ;
313
348
  const upZ = fwdX * rY - fwdY * rX;
314
- this.setListenerPosition(listenerPos, {
349
+ this.setListenerPosition(normalizedListener, {
315
350
  forwardX: fwdX,
316
351
  forwardY: fwdY,
317
352
  forwardZ: fwdZ,
@@ -332,6 +367,9 @@ class SpatialAudioManager extends EventManager_1.EventManager {
332
367
  nodes.panner.disconnect();
333
368
  nodes.analyser.disconnect();
334
369
  nodes.gain.disconnect();
370
+ if (nodes.denoiseNode) {
371
+ nodes.denoiseNode.disconnect();
372
+ }
335
373
  nodes.stream.getTracks().forEach((track) => track.stop());
336
374
  this.participantNodes.delete(participantId);
337
375
  console.log(`🗑️ Removed participant ${participantId} from spatial audio.`);
@@ -346,5 +384,153 @@ class SpatialAudioManager extends EventManager_1.EventManager {
346
384
  getAudioContextState() {
347
385
  return this.audioContext.state;
348
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
+ }
349
535
  }
350
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.9",
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",