teleportxr 1.0.85 → 1.0.87

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/client/client.js CHANGED
@@ -99,10 +99,21 @@ class Client {
99
99
  // channels are in 'open' state yet; sends issued now will throw
100
100
  // InvalidStateError. Instead, UpdateStreaming is triggered from the
101
101
  // dataChannelsOpen callback registered in StartStreaming().
102
+ // Begin streaming scene SoundComponents (server-side audio) over the
103
+ // outbound media track. Safe even if no sound components are present.
104
+ if (this.webRtcConnection && this.scene)
105
+ {
106
+ try { this.webRtcConnection.startSceneAudio(this.scene); }
107
+ catch (e) { console.error("startSceneAudio threw: "+e.message); }
108
+ }
102
109
  }
103
110
  else
104
111
  {
105
112
  this.webRtcConnected=false;
113
+ if (this.webRtcConnection)
114
+ {
115
+ try { this.webRtcConnection.stopSceneAudio(); } catch (e) {}
116
+ }
106
117
  }
107
118
  }
108
119
  // Invoked once per WebRTC connection, the moment both reliable and geometry
@@ -1,8 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  const EventEmitter = require('events');
4
+ const path = require('path');
4
5
  const wrtc =require('@roamhq/wrtc');
5
6
  const DefaultRTCPeerConnection = require('@roamhq/wrtc').RTCPeerConnection;
7
+ const sound = require('../scene/sound.js');
6
8
 
7
9
  const TIME_TO_CONNECTED = 30000;
8
10
  const TIME_TO_HOST_CANDIDATES = 3000; // NOTE: Too long.
@@ -71,6 +73,8 @@ class WebRtcConnection extends EventEmitter
71
73
  this._audioSource = null;
72
74
  this._localAudioTrack = null;
73
75
  this._audioSink = null;
76
+ this._sceneAudioStreamer = null;
77
+ this._sceneAudioInterval = null;
74
78
 
75
79
 
76
80
  Object.defineProperties(this, {
@@ -486,6 +490,7 @@ class WebRtcConnection extends EventEmitter
486
490
  };
487
491
  }
488
492
  _teardownAudioMediaTrack() {
493
+ this.stopSceneAudio();
489
494
  if (this._audioSink) {
490
495
  try { this._audioSink.stop(); } catch (e) {}
491
496
  this._audioSink = null;
@@ -496,6 +501,85 @@ class WebRtcConnection extends EventEmitter
496
501
  }
497
502
  this._audioSource = null;
498
503
  }
504
+ // Start streaming all SoundComponents in the given scene as a single mixed
505
+ // 48 kHz / mono / int16 PCM track via the outbound audio media track. Idempotent
506
+ // for a given scene; calling again replaces the active source set.
507
+ startSceneAudio(scene) {
508
+ if (!this._audioSource) {
509
+ console.warn('startSceneAudio: no audio source on this connection');
510
+ return;
511
+ }
512
+ if (!scene || typeof scene.GetSoundComponents !== 'function') {
513
+ return;
514
+ }
515
+ const components = scene.GetSoundComponents();
516
+ if (!components || components.length === 0) {
517
+ return;
518
+ }
519
+ if (!this._sceneAudioStreamer) {
520
+ this._sceneAudioStreamer = new sound.SceneAudioStreamer(this._audioSource);
521
+ } else {
522
+ this._sceneAudioStreamer.clear();
523
+ }
524
+ const publicPath = (typeof scene.GetPublicPath === 'function') ? scene.GetPublicPath() : 'http_resources';
525
+ let loaded = 0;
526
+ for (const c of components) {
527
+ if (!c.url) continue;
528
+ const rel = c.url.startsWith('/') ? c.url.slice(1) : c.url;
529
+ const filePath = path.join(publicPath, rel);
530
+ try {
531
+ const src = new sound.WavSource(filePath);
532
+ this._sceneAudioStreamer.addSource(c.url, src);
533
+ loaded++;
534
+ } catch (e) {
535
+ console.warn('startSceneAudio: failed to load '+filePath+': '+e.message);
536
+ }
537
+ }
538
+ if (loaded === 0) {
539
+ return;
540
+ }
541
+ if (this._sceneAudioInterval) {
542
+ clearInterval(this._sceneAudioInterval);
543
+ this._sceneAudioInterval = null;
544
+ }
545
+ // Drive a 10 ms tick. setInterval is sufficient: RTCAudioSource buffers
546
+ // internally, and a few ms of jitter is absorbed by the WebRTC jitter buffer.
547
+ this._sceneAudioTickCount = 0;
548
+ this._sceneAudioInterval = setInterval(() => {
549
+ if (!this._sceneAudioStreamer) return;
550
+ this._sceneAudioStreamer.tick();
551
+ this._sceneAudioTickCount++;
552
+ // Every ~5 s, log tick count + outbound RTP stats so we can confirm
553
+ // libwebrtc is actually transmitting audio packets.
554
+ if ((this._sceneAudioTickCount % 500) === 0) {
555
+ const n = this._sceneAudioTickCount;
556
+ if (typeof this.peerConnection.getStats === 'function') {
557
+ Promise.resolve(this.peerConnection.getStats()).then((report) => {
558
+ let summary = 'no outbound-rtp/audio stats';
559
+ try {
560
+ const entries = (typeof report.values === 'function') ? Array.from(report.values()) : Object.values(report);
561
+ const outRtp = entries.find((s) => s && s.type === 'outbound-rtp' && (s.kind === 'audio' || s.mediaType === 'audio'));
562
+ if (outRtp) summary = 'packetsSent='+outRtp.packetsSent+' bytesSent='+outRtp.bytesSent;
563
+ } catch (e) { summary = 'stats parse error: '+e.message; }
564
+ console.log('SceneAudioStreamer tick #'+n+' — '+summary);
565
+ }).catch((e) => { console.log('SceneAudioStreamer tick #'+n+' — getStats failed: '+e.message); });
566
+ } else {
567
+ console.log('SceneAudioStreamer tick #'+n+' — getStats unavailable');
568
+ }
569
+ }
570
+ }, sound.FRAME_MS);
571
+ console.log('startSceneAudio: streaming '+loaded+' source(s) at '+sound.SAMPLE_RATE+' Hz mono');
572
+ }
573
+ stopSceneAudio() {
574
+ if (this._sceneAudioInterval) {
575
+ clearInterval(this._sceneAudioInterval);
576
+ this._sceneAudioInterval = null;
577
+ }
578
+ if (this._sceneAudioStreamer) {
579
+ this._sceneAudioStreamer.clear();
580
+ this._sceneAudioStreamer = null;
581
+ }
582
+ }
499
583
 
500
584
  receiveMessage(id,event)
501
585
  {
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "name": "teleportxr",
18
18
  "description": "Teleport Spatial Server on node.js",
19
- "version": "1.0.85",
19
+ "version": "1.0.87",
20
20
  "repository": {
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/simul/teleport-nodejs.git"
package/scene/node.js CHANGED
@@ -227,6 +227,19 @@ class SkeletonComponent extends Component
227
227
  }
228
228
  };
229
229
 
230
+ // Server-side only. Sound components reference an audio asset on the server
231
+ // and are streamed via the WebRTC audio media track; they are never encoded
232
+ // into the scene-graph payload sent to clients.
233
+ class SoundComponent
234
+ {
235
+ constructor(url = "")
236
+ {
237
+ this.url = url;
238
+ this.loop = true;
239
+ this.gain = 1.0;
240
+ }
241
+ };
242
+
230
243
  class Node {
231
244
  constructor( name = "") {
232
245
  this.uid = core.generateUid();
@@ -240,6 +253,9 @@ class Node {
240
253
  this.priority = 0;
241
254
 
242
255
  this.components = [];
256
+ // Server-side only audio sources attached to this node. Not encoded
257
+ // into the wire payload; consumed by SceneAudioStreamer.
258
+ this.soundComponents = [];
243
259
  }
244
260
  static sizeof() {
245
261
  return 8 + 24 + Pose.size + 8;
@@ -267,6 +283,12 @@ class Node {
267
283
  );
268
284
  this.components.push(m);
269
285
  }
286
+ setSoundComponent(url) {
287
+ for (const c of this.soundComponents) {
288
+ if (c.url === url) return;
289
+ }
290
+ this.soundComponents.push(new SoundComponent(url));
291
+ }
270
292
  setCanvasComponent(canvas_path) {
271
293
  this.components.forEach((component) => {
272
294
  if (component.getType() == NodeDataType.TextCanvas) {
@@ -356,4 +378,4 @@ class Node {
356
378
  }
357
379
  };
358
380
 
359
- module.exports = {NodeDataType,Pose,PoseDynamic,NodePoseDynamic, Node };
381
+ module.exports = {NodeDataType,Pose,PoseDynamic,NodePoseDynamic, Node, SoundComponent };
package/scene/scene.js CHANGED
@@ -52,6 +52,21 @@ class Scene {
52
52
  let node_uids = Array.from(this.nodes.keys());
53
53
  return node_uids;
54
54
  }
55
+ // Returns [{ nodeUid, url, loop, gain }] for every SoundComponent in the scene.
56
+ // Sound components are server-side only and never appear in the encoded node payload.
57
+ GetSoundComponents() {
58
+ const out = [];
59
+ for (const [uid, node] of this.nodes) {
60
+ if (!node.soundComponents || node.soundComponents.length === 0) continue;
61
+ for (const sc of node.soundComponents) {
62
+ out.push({ nodeUid: uid, url: sc.url, loop: sc.loop, gain: sc.gain });
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ GetPublicPath() {
68
+ return this.publicPath;
69
+ }
55
70
  LoadFontAtlas(res, filename) {
56
71
  const data = fs.readFileSync(filename, "utf8");
57
72
  const jfa = JSON.parse(data);
@@ -252,6 +267,10 @@ class Scene {
252
267
  var canvas=c.url;
253
268
  n.setCanvasComponent(canvas);
254
269
  }
270
+ if (c["type"] == "sound")
271
+ {
272
+ n.setSoundComponent(c["url"]);
273
+ }
255
274
  }
256
275
  }
257
276
  }
package/scene/sound.js ADDED
@@ -0,0 +1,136 @@
1
+ // SPDX-FileCopyrightText: 2025 Teleport XR Ltd <contact@teleportxr.io>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ //
5
+ // Server-side audio streaming for scene "sound" components.
6
+ //
7
+ // Sound components are kept server-side: their PCM is mixed and pushed into
8
+ // an @roamhq/wrtc RTCAudioSource as 10 ms / 48 kHz / mono / int16 frames,
9
+ // which libwebrtc encodes to Opus on the outbound audio media track.
10
+ 'use strict';
11
+ const fs = require('fs');
12
+
13
+ // Required output format for RTCAudioSource.onData.
14
+ const SAMPLE_RATE = 48000;
15
+ const CHANNEL_COUNT = 1;
16
+ const FRAME_MS = 10;
17
+ const FRAME_SAMPLES = (SAMPLE_RATE * FRAME_MS) / 1000; // 480
18
+
19
+ // Parse a minimal RIFF/WAVE file. Returns { samples: Int16Array, sampleRate, channelCount }
20
+ // or throws on any unsupported variant (we only accept mono / 16-bit / 48 kHz).
21
+ function parseWav(buffer) {
22
+ if (buffer.length < 44) throw new Error('WAV: file too short');
23
+ if (buffer.toString('ascii', 0, 4) !== 'RIFF') throw new Error('WAV: missing RIFF header');
24
+ if (buffer.toString('ascii', 8, 12) !== 'WAVE') throw new Error('WAV: missing WAVE marker');
25
+ let pos = 12;
26
+ let fmt = null;
27
+ let dataOffset = -1;
28
+ let dataSize = 0;
29
+ while (pos + 8 <= buffer.length) {
30
+ const id = buffer.toString('ascii', pos, pos + 4);
31
+ const size = buffer.readUInt32LE(pos + 4);
32
+ const next = pos + 8 + size + (size & 1); // chunks are word-aligned
33
+ if (id === 'fmt ') {
34
+ fmt = {
35
+ audioFormat: buffer.readUInt16LE(pos + 8),
36
+ channels: buffer.readUInt16LE(pos + 10),
37
+ sampleRate: buffer.readUInt32LE(pos + 12),
38
+ bitsPerSample: buffer.readUInt16LE(pos + 22)
39
+ };
40
+ } else if (id === 'data') {
41
+ dataOffset = pos + 8;
42
+ dataSize = size;
43
+ break;
44
+ }
45
+ pos = next;
46
+ }
47
+ if (!fmt) throw new Error('WAV: no fmt chunk');
48
+ if (dataOffset < 0) throw new Error('WAV: no data chunk');
49
+ if (fmt.audioFormat !== 1) throw new Error('WAV: only PCM (audioFormat=1) is supported, got '+fmt.audioFormat);
50
+ if (fmt.channels !== CHANNEL_COUNT) throw new Error('WAV: only mono is supported, got channels='+fmt.channels);
51
+ if (fmt.sampleRate !== SAMPLE_RATE) throw new Error('WAV: only '+SAMPLE_RATE+' Hz is supported, got '+fmt.sampleRate);
52
+ if (fmt.bitsPerSample !== 16) throw new Error('WAV: only 16-bit PCM is supported, got bitsPerSample='+fmt.bitsPerSample);
53
+ const numSamples = Math.floor(dataSize / 2);
54
+ const samples = new Int16Array(numSamples);
55
+ for (let i = 0; i < numSamples; i++) {
56
+ samples[i] = buffer.readInt16LE(dataOffset + i * 2);
57
+ }
58
+ return { samples, sampleRate: fmt.sampleRate, channelCount: fmt.channels };
59
+ }
60
+
61
+ // One looping mono int16 PCM source. pull() writes FRAME_SAMPLES samples into
62
+ // the supplied Int32 accumulator (caller mixes; we just add).
63
+ class WavSource {
64
+ constructor(filePath) {
65
+ const buf = fs.readFileSync(filePath);
66
+ const wav = parseWav(buf);
67
+ this.samples = wav.samples;
68
+ this.position = 0;
69
+ this.filePath = filePath;
70
+ }
71
+ pull(accumulator) {
72
+ const len = this.samples.length;
73
+ if (len === 0) return;
74
+ let pos = this.position;
75
+ for (let i = 0; i < FRAME_SAMPLES; i++) {
76
+ accumulator[i] += this.samples[pos];
77
+ pos++;
78
+ if (pos >= len) pos = 0;
79
+ }
80
+ this.position = pos;
81
+ }
82
+ }
83
+
84
+ // SceneAudioStreamer mixes all active sources at 48 kHz mono int16 and
85
+ // pushes one 10 ms frame per tick into the supplied RTCAudioSource.
86
+ class SceneAudioStreamer {
87
+ constructor(audioSource) {
88
+ this.audioSource = audioSource;
89
+ this.sources = []; // [{ url, source }]
90
+ this.mixBuffer = new Int32Array(FRAME_SAMPLES);
91
+ this.outputBuffer = new Int16Array(FRAME_SAMPLES);
92
+ this.frame = {
93
+ samples: this.outputBuffer,
94
+ sampleRate: SAMPLE_RATE,
95
+ bitsPerSample: 16,
96
+ channelCount: CHANNEL_COUNT,
97
+ numberOfFrames: FRAME_SAMPLES
98
+ };
99
+ }
100
+ addSource(url, source) {
101
+ this.sources.push({ url, source });
102
+ }
103
+ clear() {
104
+ this.sources = [];
105
+ }
106
+ tick() {
107
+ const mix = this.mixBuffer;
108
+ mix.fill(0);
109
+ for (const s of this.sources) {
110
+ s.source.pull(mix);
111
+ }
112
+ // Clip to int16 range.
113
+ const out = this.outputBuffer;
114
+ for (let i = 0; i < FRAME_SAMPLES; i++) {
115
+ let v = mix[i];
116
+ if (v > 32767) v = 32767;
117
+ else if (v < -32768) v = -32768;
118
+ out[i] = v;
119
+ }
120
+ try {
121
+ this.audioSource.onData(this.frame);
122
+ } catch (e) {
123
+ console.error('SceneAudioStreamer: onData failed: '+e.message);
124
+ }
125
+ }
126
+ }
127
+
128
+ module.exports = {
129
+ SAMPLE_RATE,
130
+ CHANNEL_COUNT,
131
+ FRAME_MS,
132
+ FRAME_SAMPLES,
133
+ parseWav,
134
+ WavSource,
135
+ SceneAudioStreamer
136
+ };