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 +11 -0
- package/connections/webrtcconnection.js +84 -0
- package/package.json +1 -1
- package/scene/node.js +23 -1
- package/scene/scene.js +19 -0
- package/scene/sound.js +136 -0
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
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
|
+
};
|