remoterigs-raspberrypi 0.1.14 → 0.1.16
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remoterigs-raspberrypi",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.16",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"files": [
|
|
7
7
|
"index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@microsoft/signalr": "^8.0.7",
|
|
17
17
|
"axios": "^1.13.2",
|
|
18
|
+
"node-datachannel": "^0.33.6",
|
|
18
19
|
"pigpio": "^3.3.1"
|
|
19
20
|
}
|
|
20
21
|
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import dgram from 'dgram';
|
|
4
|
+
import { performance } from 'perf_hooks';
|
|
5
|
+
const START_CODE = Buffer.from([0, 0, 0, 1]);
|
|
6
|
+
const NAL_IDR = 5;
|
|
7
|
+
const NAL_SPS = 7;
|
|
8
|
+
const NAL_PPS = 8;
|
|
9
|
+
const NAL_AUD = 9;
|
|
10
|
+
// wall clock in ms, shared by audio and video so the timestamps of both tracks run on the same clock
|
|
11
|
+
function NowMs() {
|
|
12
|
+
return performance.timeOrigin + performance.now();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Captures the webcam (and optionally the microphone) with ffmpeg and emits encoded frames:
|
|
16
|
+
* - 'video' (VideoFrame): H264, raw Annex-B from ffmpeg stdout, split into access units here
|
|
17
|
+
* - 'audio' (AudioFrame): Opus, RTP from ffmpeg to a local UDP socket, unpacked here
|
|
18
|
+
*
|
|
19
|
+
* One capture per camera, shared by all viewers. The processes only run while there are subscribers.
|
|
20
|
+
*/
|
|
21
|
+
export default class CaptureSource extends EventEmitter {
|
|
22
|
+
constructor(settings, log) {
|
|
23
|
+
super();
|
|
24
|
+
this.settings = settings;
|
|
25
|
+
this.log = log;
|
|
26
|
+
this.videoProcess = null;
|
|
27
|
+
this.audioProcess = null;
|
|
28
|
+
this.audioSocket = null;
|
|
29
|
+
this.running = false;
|
|
30
|
+
this.subscribers = 0;
|
|
31
|
+
this.stopTimer = null;
|
|
32
|
+
this.restartTimers = new Set();
|
|
33
|
+
// mjpeg is the format most USB webcams need for 720p/1080p at 30 fps; not every camera supports it
|
|
34
|
+
this.useDefaultInputFormat = false;
|
|
35
|
+
// H264 parsing
|
|
36
|
+
this.videoBuffer = Buffer.alloc(0);
|
|
37
|
+
this.currentAccessUnit = [];
|
|
38
|
+
this.currentAccessUnitTime = 0;
|
|
39
|
+
this.sps = null;
|
|
40
|
+
this.pps = null;
|
|
41
|
+
this.framesEmitted = 0;
|
|
42
|
+
// audio timestamps: ffmpeg RTP timestamp + offset = wall clock in 48 kHz
|
|
43
|
+
this.audioTimestampOffset = null;
|
|
44
|
+
this.resolvedAudioDevice = null;
|
|
45
|
+
// keep the camera open a bit after the last viewer left, a reconnect (page reload) is then instant
|
|
46
|
+
this.STOP_DELAY_MS = 15000;
|
|
47
|
+
this.RESTART_DELAY_MS = 2000;
|
|
48
|
+
this.setMaxListeners(100);
|
|
49
|
+
}
|
|
50
|
+
get HasAudio() {
|
|
51
|
+
return this.settings.audioPath != "";
|
|
52
|
+
}
|
|
53
|
+
Subscribe() {
|
|
54
|
+
this.subscribers++;
|
|
55
|
+
if (this.stopTimer != null) {
|
|
56
|
+
clearTimeout(this.stopTimer);
|
|
57
|
+
this.stopTimer = null;
|
|
58
|
+
}
|
|
59
|
+
if (!this.running) {
|
|
60
|
+
this.StartCapture();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
Unsubscribe() {
|
|
64
|
+
this.subscribers = Math.max(0, this.subscribers - 1);
|
|
65
|
+
if (this.subscribers == 0 && this.running && this.stopTimer == null) {
|
|
66
|
+
this.stopTimer = setTimeout(() => {
|
|
67
|
+
this.stopTimer = null;
|
|
68
|
+
if (this.subscribers == 0) {
|
|
69
|
+
this.log("no viewers left, stopping capture");
|
|
70
|
+
this.StopCapture();
|
|
71
|
+
}
|
|
72
|
+
}, this.STOP_DELAY_MS);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
Dispose() {
|
|
76
|
+
if (this.stopTimer != null) {
|
|
77
|
+
clearTimeout(this.stopTimer);
|
|
78
|
+
this.stopTimer = null;
|
|
79
|
+
}
|
|
80
|
+
this.subscribers = 0;
|
|
81
|
+
this.StopCapture();
|
|
82
|
+
this.removeAllListeners();
|
|
83
|
+
}
|
|
84
|
+
StartCapture() {
|
|
85
|
+
this.running = true;
|
|
86
|
+
this.StartVideo();
|
|
87
|
+
if (this.HasAudio) {
|
|
88
|
+
this.StartAudio().catch(err => this.log("audio start failed: " + err));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
StopCapture() {
|
|
92
|
+
this.running = false;
|
|
93
|
+
for (const timer of this.restartTimers) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
this.restartTimers.clear();
|
|
97
|
+
if (this.videoProcess != null) {
|
|
98
|
+
this.videoProcess.kill("SIGKILL");
|
|
99
|
+
this.videoProcess = null;
|
|
100
|
+
}
|
|
101
|
+
if (this.audioProcess != null) {
|
|
102
|
+
this.audioProcess.kill("SIGKILL");
|
|
103
|
+
this.audioProcess = null;
|
|
104
|
+
}
|
|
105
|
+
if (this.audioSocket != null) {
|
|
106
|
+
this.audioSocket.close();
|
|
107
|
+
this.audioSocket = null;
|
|
108
|
+
}
|
|
109
|
+
this.videoBuffer = Buffer.alloc(0);
|
|
110
|
+
this.currentAccessUnit = [];
|
|
111
|
+
this.audioTimestampOffset = null;
|
|
112
|
+
}
|
|
113
|
+
ScheduleRestart(what, start) {
|
|
114
|
+
if (!this.running) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.log(`${what} stopped, restarting in ${this.RESTART_DELAY_MS} ms`);
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
this.restartTimers.delete(timer);
|
|
120
|
+
if (this.running) {
|
|
121
|
+
start();
|
|
122
|
+
}
|
|
123
|
+
}, this.RESTART_DELAY_MS);
|
|
124
|
+
this.restartTimers.add(timer);
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------- video
|
|
127
|
+
BuildVideoArgs() {
|
|
128
|
+
const s = this.settings;
|
|
129
|
+
const inputFormat = s.inputFormat != "" ? s.inputFormat : (this.useDefaultInputFormat ? "" : "mjpeg");
|
|
130
|
+
const args = [
|
|
131
|
+
"-hide_banner", "-loglevel", "warning", "-nostdin",
|
|
132
|
+
"-fflags", "nobuffer", "-flags", "low_delay",
|
|
133
|
+
];
|
|
134
|
+
if (s.devicePath.startsWith("lavfi:")) {
|
|
135
|
+
// test source without a camera, e.g. devicepath = "lavfi:testsrc2"
|
|
136
|
+
const source = s.devicePath.substring(6);
|
|
137
|
+
const size = s.width > 0 && s.height > 0 ? `:size=${s.width}x${s.height}` : "";
|
|
138
|
+
args.push("-re", "-f", "lavfi", "-i", `${source}${source.includes("=") ? ":" : "="}rate=${s.fps}${size}`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
args.push("-f", "v4l2", "-thread_queue_size", "64");
|
|
142
|
+
if (inputFormat != "") {
|
|
143
|
+
args.push("-input_format", inputFormat);
|
|
144
|
+
}
|
|
145
|
+
if (s.width > 0 && s.height > 0) {
|
|
146
|
+
args.push("-video_size", `${s.width}x${s.height}`);
|
|
147
|
+
}
|
|
148
|
+
if (s.fps > 0) {
|
|
149
|
+
args.push("-framerate", String(s.fps));
|
|
150
|
+
}
|
|
151
|
+
args.push("-i", s.devicePath);
|
|
152
|
+
}
|
|
153
|
+
args.push("-an");
|
|
154
|
+
if (inputFormat == "h264") {
|
|
155
|
+
// the camera encodes itself: no CPU / encoder load at all
|
|
156
|
+
args.push("-c:v", "copy");
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
args.push("-c:v", s.encoder, "-pix_fmt", "yuv420p", "-b:v", `${s.videoBitrateKbps}k`, "-maxrate", `${s.videoBitrateKbps}k`, "-bufsize", `${s.videoBitrateKbps}k`,
|
|
160
|
+
// a short GOP: a viewer that connects (or loses a packet) waits at most this long for a picture
|
|
161
|
+
"-g", String(s.keyframeIntervalFrames), "-keyint_min", String(s.keyframeIntervalFrames));
|
|
162
|
+
if (s.encoder == "libx264") {
|
|
163
|
+
args.push("-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline");
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// 66 = H264 baseline (the v4l2m2m wrapper only takes the numeric value)
|
|
167
|
+
args.push("-profile:v", "66");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
args.push(
|
|
171
|
+
// an access unit delimiter in front of every frame: makes splitting the stream into frames trivial
|
|
172
|
+
"-bsf:v", "h264_metadata=aud=insert", "-flush_packets", "1", "-f", "h264", "pipe:1");
|
|
173
|
+
return args;
|
|
174
|
+
}
|
|
175
|
+
StartVideo() {
|
|
176
|
+
const args = this.BuildVideoArgs();
|
|
177
|
+
const startedAt = Date.now();
|
|
178
|
+
let lastStderr = "";
|
|
179
|
+
this.log("starting video: ffmpeg " + args.join(" "));
|
|
180
|
+
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
181
|
+
this.videoProcess = proc;
|
|
182
|
+
this.videoBuffer = Buffer.alloc(0);
|
|
183
|
+
this.currentAccessUnit = [];
|
|
184
|
+
this.framesEmitted = 0;
|
|
185
|
+
proc.stdout.on("data", (chunk) => this.OnVideoData(chunk));
|
|
186
|
+
proc.stderr.on("data", (data) => {
|
|
187
|
+
lastStderr = data.toString().trim();
|
|
188
|
+
this.log("ffmpeg video: " + lastStderr);
|
|
189
|
+
});
|
|
190
|
+
proc.on("error", (err) => {
|
|
191
|
+
this.log("ffmpeg video error: " + err.message);
|
|
192
|
+
});
|
|
193
|
+
proc.on("exit", (code, signal) => {
|
|
194
|
+
if (this.videoProcess !== proc) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.videoProcess = null;
|
|
198
|
+
this.log(`ffmpeg video exited (code ${code}, signal ${signal}) after ${this.framesEmitted} frames`);
|
|
199
|
+
// the camera does not do mjpeg: try again with its default format
|
|
200
|
+
if (this.settings.inputFormat == "" && !this.useDefaultInputFormat && this.framesEmitted == 0 && Date.now() - startedAt < 10000) {
|
|
201
|
+
this.log("no frames with input format mjpeg, falling back to the camera default format");
|
|
202
|
+
this.useDefaultInputFormat = true;
|
|
203
|
+
}
|
|
204
|
+
this.ScheduleRestart("video", () => this.StartVideo());
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
OnVideoData(chunk) {
|
|
208
|
+
const now = NowMs();
|
|
209
|
+
const buf = this.videoBuffer.length > 0 ? Buffer.concat([this.videoBuffer, chunk]) : chunk;
|
|
210
|
+
// find all start codes (00 00 01 or 00 00 00 01)
|
|
211
|
+
const starts = [];
|
|
212
|
+
for (let i = 0; i + 2 < buf.length; i++) {
|
|
213
|
+
if (buf[i] === 0 && buf[i + 1] === 0) {
|
|
214
|
+
if (buf[i + 2] === 1) {
|
|
215
|
+
starts.push({ pos: i, len: 3 });
|
|
216
|
+
i += 2;
|
|
217
|
+
}
|
|
218
|
+
else if (buf[i + 2] === 0 && i + 3 < buf.length && buf[i + 3] === 1) {
|
|
219
|
+
starts.push({ pos: i, len: 4 });
|
|
220
|
+
i += 3;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (starts.length == 0) {
|
|
225
|
+
this.videoBuffer = Buffer.from(buf);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// every NAL unit between two start codes is complete; the last one may still grow
|
|
229
|
+
for (let k = 0; k < starts.length - 1; k++) {
|
|
230
|
+
const nal = buf.subarray(starts[k].pos + starts[k].len, starts[k + 1].pos);
|
|
231
|
+
if (nal.length > 0) {
|
|
232
|
+
this.OnNalUnit(nal, now);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
this.videoBuffer = Buffer.from(buf.subarray(starts[starts.length - 1].pos));
|
|
236
|
+
}
|
|
237
|
+
OnNalUnit(nal, now) {
|
|
238
|
+
const type = nal[0] & 0x1f;
|
|
239
|
+
if (type == NAL_AUD) {
|
|
240
|
+
// start of the next frame: the current one is complete
|
|
241
|
+
if (this.currentAccessUnit.length > 0) {
|
|
242
|
+
this.EmitAccessUnit();
|
|
243
|
+
}
|
|
244
|
+
this.currentAccessUnitTime = now;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (this.currentAccessUnit.length == 0 && this.currentAccessUnitTime == 0) {
|
|
248
|
+
this.currentAccessUnitTime = now;
|
|
249
|
+
}
|
|
250
|
+
if (type == NAL_SPS) {
|
|
251
|
+
this.sps = Buffer.from(nal);
|
|
252
|
+
}
|
|
253
|
+
else if (type == NAL_PPS) {
|
|
254
|
+
this.pps = Buffer.from(nal);
|
|
255
|
+
}
|
|
256
|
+
this.currentAccessUnit.push(nal);
|
|
257
|
+
}
|
|
258
|
+
EmitAccessUnit() {
|
|
259
|
+
const nals = this.currentAccessUnit;
|
|
260
|
+
this.currentAccessUnit = [];
|
|
261
|
+
const types = nals.map(n => n[0] & 0x1f);
|
|
262
|
+
const keyframe = types.includes(NAL_IDR);
|
|
263
|
+
// the browser can only start decoding at a keyframe WITH SPS/PPS: the encoder does not always repeat them
|
|
264
|
+
if (keyframe && !types.includes(NAL_SPS) && this.sps != null && this.pps != null) {
|
|
265
|
+
nals.unshift(this.sps, this.pps);
|
|
266
|
+
}
|
|
267
|
+
const parts = [];
|
|
268
|
+
for (const nal of nals) {
|
|
269
|
+
parts.push(START_CODE, nal);
|
|
270
|
+
}
|
|
271
|
+
const frame = {
|
|
272
|
+
data: Buffer.concat(parts),
|
|
273
|
+
timestamp: Math.round(this.currentAccessUnitTime * 90) >>> 0,
|
|
274
|
+
keyframe: keyframe,
|
|
275
|
+
};
|
|
276
|
+
this.framesEmitted++;
|
|
277
|
+
this.emit("video", frame);
|
|
278
|
+
}
|
|
279
|
+
// ---------------------------------------------------------------- audio
|
|
280
|
+
ResolveAudioDevice(audioPath) {
|
|
281
|
+
let path = audioPath.trim();
|
|
282
|
+
// webrtc-streamer style: audiocap://1
|
|
283
|
+
const capMatch = /^audiocap:\/\/(\d+)$/.exec(path);
|
|
284
|
+
if (capMatch) {
|
|
285
|
+
path = capMatch[1];
|
|
286
|
+
}
|
|
287
|
+
// ALSA device names (and the lavfi test source) can be used as they are
|
|
288
|
+
if (/^(lavfi|hw|plughw|default|sysdefault|dsnoop|front):?/.test(path)) {
|
|
289
|
+
return path;
|
|
290
|
+
}
|
|
291
|
+
// a card number
|
|
292
|
+
if (/^\d+$/.test(path)) {
|
|
293
|
+
return `plughw:${path},0`;
|
|
294
|
+
}
|
|
295
|
+
// a (part of a) card name, e.g. "C920" or "USB Camera"
|
|
296
|
+
try {
|
|
297
|
+
const result = spawnSync("arecord", ["-l"], { encoding: "utf8" });
|
|
298
|
+
const lines = (result.stdout ?? "").split("\n");
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
// card 1: C920 [HD Pro Webcam C920], device 0: USB Audio [USB Audio]
|
|
301
|
+
const m = /^card (\d+): (\S+) \[([^\]]*)\], device (\d+)/.exec(line);
|
|
302
|
+
if (m && (line.toLowerCase().includes(path.toLowerCase()))) {
|
|
303
|
+
return `plughw:CARD=${m[2]},DEV=${m[4]}`;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
this.log("arecord -l failed: " + err);
|
|
309
|
+
}
|
|
310
|
+
this.log(`audio device '${audioPath}' not found in 'arecord -l', using it as ALSA device name`);
|
|
311
|
+
return path;
|
|
312
|
+
}
|
|
313
|
+
async StartAudio() {
|
|
314
|
+
if (this.resolvedAudioDevice == null) {
|
|
315
|
+
this.resolvedAudioDevice = this.ResolveAudioDevice(this.settings.audioPath);
|
|
316
|
+
this.log(`audio device '${this.settings.audioPath}' -> '${this.resolvedAudioDevice}'`);
|
|
317
|
+
}
|
|
318
|
+
if (this.audioSocket == null) {
|
|
319
|
+
const socket = dgram.createSocket("udp4");
|
|
320
|
+
socket.on("message", (msg) => this.OnAudioRtp(msg));
|
|
321
|
+
socket.on("error", (err) => this.log("audio socket error: " + err.message));
|
|
322
|
+
await new Promise(resolve => socket.bind(0, "127.0.0.1", () => resolve()));
|
|
323
|
+
this.audioSocket = socket;
|
|
324
|
+
}
|
|
325
|
+
if (!this.running) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const port = this.audioSocket.address().port;
|
|
329
|
+
const args = [
|
|
330
|
+
"-hide_banner", "-loglevel", "warning", "-nostdin",
|
|
331
|
+
...(this.resolvedAudioDevice.startsWith("lavfi:")
|
|
332
|
+
// test source without a microphone, e.g. audiopath = "lavfi:sine=frequency=440"
|
|
333
|
+
? ["-re", "-f", "lavfi", "-i", this.resolvedAudioDevice.substring(6), "-ac", "1", "-ar", "48000"]
|
|
334
|
+
: ["-f", "alsa", "-thread_queue_size", "1024", "-ac", "1", "-ar", "48000", "-i", this.resolvedAudioDevice]),
|
|
335
|
+
"-vn",
|
|
336
|
+
"-c:a", "libopus",
|
|
337
|
+
"-b:a", `${this.settings.audioBitrateKbps}k`,
|
|
338
|
+
"-application", "lowdelay",
|
|
339
|
+
"-frame_duration", "20",
|
|
340
|
+
"-payload_type", "111",
|
|
341
|
+
"-f", "rtp",
|
|
342
|
+
// RTCP to the same socket (we ignore it), not to port+1 which may belong to someone else
|
|
343
|
+
`rtp://127.0.0.1:${port}?rtcpport=${port}&pkt_size=1200`,
|
|
344
|
+
];
|
|
345
|
+
this.log("starting audio: ffmpeg " + args.join(" "));
|
|
346
|
+
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
347
|
+
this.audioProcess = proc;
|
|
348
|
+
this.audioTimestampOffset = null;
|
|
349
|
+
proc.stderr.on("data", (data) => {
|
|
350
|
+
this.log("ffmpeg audio: " + data.toString().trim());
|
|
351
|
+
});
|
|
352
|
+
proc.on("error", (err) => {
|
|
353
|
+
this.log("ffmpeg audio error: " + err.message);
|
|
354
|
+
});
|
|
355
|
+
proc.on("exit", (code, signal) => {
|
|
356
|
+
if (this.audioProcess !== proc) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.audioProcess = null;
|
|
360
|
+
this.log(`ffmpeg audio exited (code ${code}, signal ${signal})`);
|
|
361
|
+
this.ScheduleRestart("audio", () => { this.StartAudio().catch(err => this.log("audio restart failed: " + err)); });
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
OnAudioRtp(packet) {
|
|
365
|
+
if (packet.length < 12 || (packet[0] >> 6) != 2) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// RTCP (sender reports of ffmpeg): payload type 200..206
|
|
369
|
+
const payloadTypeByte = packet[1];
|
|
370
|
+
if (payloadTypeByte >= 200 && payloadTypeByte <= 206) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const padding = (packet[0] >> 5) & 1;
|
|
374
|
+
const extension = (packet[0] >> 4) & 1;
|
|
375
|
+
const csrcCount = packet[0] & 0x0f;
|
|
376
|
+
let offset = 12 + 4 * csrcCount;
|
|
377
|
+
if (extension) {
|
|
378
|
+
if (packet.length < offset + 4) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
offset += 4 + 4 * packet.readUInt16BE(offset + 2);
|
|
382
|
+
}
|
|
383
|
+
let end = packet.length;
|
|
384
|
+
if (padding) {
|
|
385
|
+
end -= packet[packet.length - 1];
|
|
386
|
+
}
|
|
387
|
+
if (end <= offset) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const rtpTimestamp = packet.readUInt32BE(4);
|
|
391
|
+
if (this.audioTimestampOffset == null) {
|
|
392
|
+
this.audioTimestampOffset = Math.round(NowMs() * 48) - rtpTimestamp;
|
|
393
|
+
}
|
|
394
|
+
const frame = {
|
|
395
|
+
data: Buffer.from(packet.subarray(offset, end)),
|
|
396
|
+
timestamp: (rtpTimestamp + this.audioTimestampOffset) >>> 0,
|
|
397
|
+
};
|
|
398
|
+
this.emit("audio", frame);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
//# sourceMappingURL=capture-source.js.map
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import nodeDataChannel from 'node-datachannel';
|
|
2
|
+
import { randomInt } from 'crypto';
|
|
3
|
+
import { LogSettingType, VideoType, WebRTCConnectionType, WebRTCMessage } from '../../models/models.js';
|
|
4
|
+
import SignalR from '../signalr.js';
|
|
5
|
+
import Debug from '../debug.js';
|
|
6
|
+
import CaptureSource from './capture-source.js';
|
|
7
|
+
// the payload types match what the relay (SIPSorcery) and the browser already expect from webrtc-streamer
|
|
8
|
+
const H264_PAYLOAD_TYPE = 100;
|
|
9
|
+
const OPUS_PAYLOAD_TYPE = 111;
|
|
10
|
+
// the browser only accepts this H264 variant (setCodecPreferences in WebRTCConnection.ts), the relay uses the same
|
|
11
|
+
const H264_FMTP = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f";
|
|
12
|
+
const VIDEO_MID = "0";
|
|
13
|
+
const AUDIO_MID = "1";
|
|
14
|
+
const STREAM_ID = "remoterigs";
|
|
15
|
+
// the relay on the server (LocalMediaRelay) does not handle audio yet: only send audio on direct connections
|
|
16
|
+
const AUDIO_ON_RELAY = false;
|
|
17
|
+
// how long a peer may stay 'disconnected' before it is closed (libdatachannel can recover from a short drop)
|
|
18
|
+
const DISCONNECTED_TIMEOUT_MS = 15000;
|
|
19
|
+
/**
|
|
20
|
+
* WebRTC streamer without the webrtc-streamer binary:
|
|
21
|
+
* - node-datachannel (libdatachannel) is the WebRTC peer (ICE/STUN/TURN, DTLS-SRTP, RTP/RTCP)
|
|
22
|
+
* - ffmpeg captures and encodes (CaptureSource), h264_v4l2m2m = the hardware encoder of the Pi 4
|
|
23
|
+
* - the signaling is the same as with WebRTCStreamerWrapper: the rig makes the offer (createOffer),
|
|
24
|
+
* the browser / relay answers (setAnswer), candidates are trickled in both directions
|
|
25
|
+
*/
|
|
26
|
+
export default class NativeWebRTCStreamer {
|
|
27
|
+
static { this.loggerInitialized = false; }
|
|
28
|
+
constructor(component, clientRTCConfigurationViewModel, videoType) {
|
|
29
|
+
this.component = component;
|
|
30
|
+
this.clientRTCConfigurationViewModel = clientRTCConfigurationViewModel;
|
|
31
|
+
this.videoType = videoType;
|
|
32
|
+
this.peers = new Map();
|
|
33
|
+
this.capture = null;
|
|
34
|
+
this.started = false;
|
|
35
|
+
this.onVideo = (frame) => this.SendVideo(frame);
|
|
36
|
+
this.onAudio = (frame) => this.SendAudio(frame);
|
|
37
|
+
}
|
|
38
|
+
async Start() {
|
|
39
|
+
this.component.SetStatusState("State", "Starting");
|
|
40
|
+
if (!NativeWebRTCStreamer.loggerInitialized) {
|
|
41
|
+
NativeWebRTCStreamer.loggerInitialized = true;
|
|
42
|
+
nodeDataChannel.initLogger("Warning", (level, message) => {
|
|
43
|
+
Debug.LogAlways("libdatachannel", `${level}: ${message}`, false);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (this.videoType != VideoType.H264) {
|
|
47
|
+
this.Log("WARNING: the native streamer only sends H264 (the rig is set to " + VideoType[this.videoType] + ")");
|
|
48
|
+
}
|
|
49
|
+
const settings = this.GetCaptureSettings();
|
|
50
|
+
this.Log("capture settings: " + JSON.stringify(settings));
|
|
51
|
+
this.capture = new CaptureSource(settings, (message) => this.Log(message));
|
|
52
|
+
this.capture.on("video", this.onVideo);
|
|
53
|
+
this.capture.on("audio", this.onAudio);
|
|
54
|
+
this.started = true;
|
|
55
|
+
this.component.SetStatusState("State", "Started");
|
|
56
|
+
this.Log("native streamer is ready (libdatachannel " + nodeDataChannel.getLibraryVersion() + ")");
|
|
57
|
+
}
|
|
58
|
+
async Stop() {
|
|
59
|
+
this.started = false;
|
|
60
|
+
for (const peer of [...this.peers.values()]) {
|
|
61
|
+
this.ClosePeer(peer, "streamer stopped");
|
|
62
|
+
}
|
|
63
|
+
if (this.capture != null) {
|
|
64
|
+
this.capture.Dispose();
|
|
65
|
+
this.capture = null;
|
|
66
|
+
}
|
|
67
|
+
this.component.SetStatusState("State", "Stopped");
|
|
68
|
+
}
|
|
69
|
+
async PutWebRTCMessage(message) {
|
|
70
|
+
if (!this.started) {
|
|
71
|
+
this.Log("IGNORED! PutWebRTCMessage userId: " + message.userId + " messageType: " + message.messageType);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.Log("PutWebRTCMessage userId: " + message.userId + " messageType: " + message.messageType + " session: " + message.sessionId);
|
|
75
|
+
try {
|
|
76
|
+
switch (message.messageType) {
|
|
77
|
+
case "createOffer":
|
|
78
|
+
this.CreateOffer(message);
|
|
79
|
+
break;
|
|
80
|
+
case "setAnswer":
|
|
81
|
+
this.SetAnswer(message);
|
|
82
|
+
break;
|
|
83
|
+
case "addIceCandidate":
|
|
84
|
+
this.AddIceCandidate(message);
|
|
85
|
+
break;
|
|
86
|
+
case "hangup":
|
|
87
|
+
this.Hangup(message);
|
|
88
|
+
break;
|
|
89
|
+
case "getIceServers":
|
|
90
|
+
this.GetIceServers(message);
|
|
91
|
+
break;
|
|
92
|
+
case "onReceiveCall":
|
|
93
|
+
// only used with the 'call' flow of webrtc-streamer
|
|
94
|
+
break;
|
|
95
|
+
case "call":
|
|
96
|
+
this.Error("'call' (browser makes the offer) is not supported by the native streamer, use createOffer");
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
this.Log("unknown messageType: " + message.messageType);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
this.Error(`${message.messageType} failed: ${err?.message ?? err}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------- signaling
|
|
108
|
+
CreateOffer(message) {
|
|
109
|
+
if (this.capture == null) {
|
|
110
|
+
throw new Error("capture not started");
|
|
111
|
+
}
|
|
112
|
+
// a new offer for an existing session: the browser starts over
|
|
113
|
+
const existing = this.peers.get(message.sessionId);
|
|
114
|
+
if (existing != null) {
|
|
115
|
+
this.ClosePeer(existing, "new offer for the same session");
|
|
116
|
+
}
|
|
117
|
+
const pc = new nodeDataChannel.PeerConnection(message.sessionId, {
|
|
118
|
+
iceServers: this.GetIceServerConfig(),
|
|
119
|
+
disableAutoNegotiation: true,
|
|
120
|
+
});
|
|
121
|
+
// video
|
|
122
|
+
const videoSsrc = randomInt(1, 0x7fffffff);
|
|
123
|
+
const video = new nodeDataChannel.Video(VIDEO_MID, "SendOnly");
|
|
124
|
+
video.addH264Codec(H264_PAYLOAD_TYPE, H264_FMTP);
|
|
125
|
+
video.addSSRC(videoSsrc, "video", STREAM_ID, "video");
|
|
126
|
+
const videoTrack = pc.addTrack(video);
|
|
127
|
+
const videoConfig = new nodeDataChannel.RtpPacketizationConfig(videoSsrc, "video", H264_PAYLOAD_TYPE, 90000);
|
|
128
|
+
const videoPacketizer = new nodeDataChannel.H264RtpPacketizer("StartSequence", videoConfig);
|
|
129
|
+
videoPacketizer.addToChain(new nodeDataChannel.RtcpSrReporter(videoConfig));
|
|
130
|
+
videoPacketizer.addToChain(new nodeDataChannel.RtcpNackResponder());
|
|
131
|
+
videoTrack.setMediaHandler(videoPacketizer);
|
|
132
|
+
// audio
|
|
133
|
+
let audioTrack = null;
|
|
134
|
+
let audioConfig = null;
|
|
135
|
+
const withAudio = this.capture.HasAudio && (message.webRTCConnectionType != WebRTCConnectionType.Relay || AUDIO_ON_RELAY);
|
|
136
|
+
if (withAudio) {
|
|
137
|
+
const audioSsrc = randomInt(1, 0x7fffffff);
|
|
138
|
+
const audio = new nodeDataChannel.Audio(AUDIO_MID, "SendOnly");
|
|
139
|
+
audio.addOpusCodec(OPUS_PAYLOAD_TYPE);
|
|
140
|
+
audio.addSSRC(audioSsrc, "audio", STREAM_ID, "audio");
|
|
141
|
+
audioTrack = pc.addTrack(audio);
|
|
142
|
+
audioConfig = new nodeDataChannel.RtpPacketizationConfig(audioSsrc, "audio", OPUS_PAYLOAD_TYPE, 48000);
|
|
143
|
+
const audioPacketizer = new nodeDataChannel.RtpPacketizer(audioConfig);
|
|
144
|
+
audioPacketizer.addToChain(new nodeDataChannel.RtcpSrReporter(audioConfig));
|
|
145
|
+
audioPacketizer.addToChain(new nodeDataChannel.RtcpNackResponder());
|
|
146
|
+
audioTrack.setMediaHandler(audioPacketizer);
|
|
147
|
+
}
|
|
148
|
+
const peer = {
|
|
149
|
+
sessionId: message.sessionId,
|
|
150
|
+
pc: pc,
|
|
151
|
+
message: message,
|
|
152
|
+
videoTrack: videoTrack,
|
|
153
|
+
videoConfig: videoConfig,
|
|
154
|
+
audioTrack: audioTrack,
|
|
155
|
+
audioConfig: audioConfig,
|
|
156
|
+
waitingForKeyframe: true,
|
|
157
|
+
remoteDescriptionSet: false,
|
|
158
|
+
pendingRemoteCandidates: [],
|
|
159
|
+
pendingLocalCandidates: [],
|
|
160
|
+
disconnectedTimer: null,
|
|
161
|
+
closed: false,
|
|
162
|
+
framesSent: 0,
|
|
163
|
+
};
|
|
164
|
+
this.peers.set(message.sessionId, peer);
|
|
165
|
+
pc.onLocalDescription((sdp, type) => {
|
|
166
|
+
this.Log(`local description (${type}) for session ${peer.sessionId}:\r\n${sdp}`);
|
|
167
|
+
this.SendToServer(peer, "receiveOffer", JSON.stringify({ type: type, sdp: sdp }));
|
|
168
|
+
});
|
|
169
|
+
pc.onLocalCandidate((candidate, mid) => {
|
|
170
|
+
// like webrtc-streamer: the candidates go out after the answer, the other side then has a peer to add them to
|
|
171
|
+
if (peer.remoteDescriptionSet) {
|
|
172
|
+
this.SendLocalCandidate(peer, candidate, mid);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
peer.pendingLocalCandidates.push({ candidate, mid });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
pc.onStateChange((state) => this.OnPeerStateChange(peer, state));
|
|
179
|
+
videoTrack.onOpen(() => {
|
|
180
|
+
this.Log(`video track open for session ${peer.sessionId}, waiting for a keyframe`);
|
|
181
|
+
peer.waitingForKeyframe = true;
|
|
182
|
+
});
|
|
183
|
+
videoTrack.onMessage((msg) => this.OnRtcp(peer, msg));
|
|
184
|
+
this.capture.Subscribe();
|
|
185
|
+
this.Log(`creating offer for session ${peer.sessionId} (${WebRTCConnectionType[message.webRTCConnectionType]}, audio: ${withAudio})`);
|
|
186
|
+
pc.setLocalDescription();
|
|
187
|
+
}
|
|
188
|
+
SetAnswer(message) {
|
|
189
|
+
const peer = this.peers.get(message.sessionId);
|
|
190
|
+
if (peer == null) {
|
|
191
|
+
this.Log("setAnswer for unknown session " + message.sessionId);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const answer = JSON.parse(message.message);
|
|
195
|
+
this.Log(`remote description (answer) for session ${peer.sessionId}:\r\n${answer.sdp}`);
|
|
196
|
+
peer.pc.setRemoteDescription(answer.sdp, "answer");
|
|
197
|
+
peer.remoteDescriptionSet = true;
|
|
198
|
+
for (const c of peer.pendingRemoteCandidates) {
|
|
199
|
+
this.ApplyRemoteCandidate(peer, c.candidate, c.mid);
|
|
200
|
+
}
|
|
201
|
+
peer.pendingRemoteCandidates = [];
|
|
202
|
+
for (const c of peer.pendingLocalCandidates) {
|
|
203
|
+
this.SendLocalCandidate(peer, c.candidate, c.mid);
|
|
204
|
+
}
|
|
205
|
+
peer.pendingLocalCandidates = [];
|
|
206
|
+
}
|
|
207
|
+
AddIceCandidate(message) {
|
|
208
|
+
const peer = this.peers.get(message.sessionId);
|
|
209
|
+
if (peer == null) {
|
|
210
|
+
this.Log("addIceCandidate for unknown session " + message.sessionId);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const init = JSON.parse(message.message);
|
|
214
|
+
const candidate = init.candidate ?? "";
|
|
215
|
+
if (candidate == "") {
|
|
216
|
+
// end of candidates
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const mid = init.sdpMid ?? (init.sdpMLineIndex != null ? String(init.sdpMLineIndex) : VIDEO_MID);
|
|
220
|
+
if (!peer.remoteDescriptionSet) {
|
|
221
|
+
peer.pendingRemoteCandidates.push({ candidate, mid });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.ApplyRemoteCandidate(peer, candidate, mid);
|
|
225
|
+
}
|
|
226
|
+
ApplyRemoteCandidate(peer, candidate, mid) {
|
|
227
|
+
try {
|
|
228
|
+
this.Log(`remote candidate for session ${peer.sessionId}: ${candidate}`);
|
|
229
|
+
peer.pc.addRemoteCandidate(candidate, mid);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
// e.g. an mDNS (.local) candidate that cannot be resolved: the other candidates still work
|
|
233
|
+
this.Log(`addRemoteCandidate failed (${candidate}): ${err?.message ?? err}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
SendLocalCandidate(peer, candidate, mid) {
|
|
237
|
+
this.Log(`local candidate for session ${peer.sessionId}: ${candidate}`);
|
|
238
|
+
const init = {
|
|
239
|
+
candidate: candidate.startsWith("a=") ? candidate.substring(2) : candidate,
|
|
240
|
+
sdpMid: mid,
|
|
241
|
+
sdpMLineIndex: mid == AUDIO_MID ? 1 : 0,
|
|
242
|
+
};
|
|
243
|
+
this.SendToServer(peer, "onReceiveCandidate", JSON.stringify(init));
|
|
244
|
+
}
|
|
245
|
+
Hangup(message) {
|
|
246
|
+
const peer = this.peers.get(message.sessionId);
|
|
247
|
+
if (peer != null) {
|
|
248
|
+
this.ClosePeer(peer, "hangup");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
GetIceServers(message) {
|
|
252
|
+
// same format as /api/getIceServers of webrtc-streamer
|
|
253
|
+
const iceServers = [];
|
|
254
|
+
const stun = this.clientRTCConfigurationViewModel.stunServer ?? "";
|
|
255
|
+
const turn = this.ParseTurnServer();
|
|
256
|
+
if (stun != "") {
|
|
257
|
+
iceServers.push({ urls: [`stun:${stun}`] });
|
|
258
|
+
}
|
|
259
|
+
if (turn != null) {
|
|
260
|
+
iceServers.push({ urls: [`turn:${turn.hostname}:${turn.port}`], username: turn.username, credential: turn.password });
|
|
261
|
+
}
|
|
262
|
+
const response = new WebRTCMessage();
|
|
263
|
+
response.componentId = this.component.component.id;
|
|
264
|
+
response.rigId = message.rigId;
|
|
265
|
+
response.webRTCConnectionType = message.webRTCConnectionType;
|
|
266
|
+
response.sessionId = message.sessionId;
|
|
267
|
+
response.userId = message.userId;
|
|
268
|
+
response.rigOwnerUserId = message.rigOwnerUserId;
|
|
269
|
+
response.messageType = "getIceServersResponse";
|
|
270
|
+
response.message = JSON.stringify({ iceServers });
|
|
271
|
+
SignalR.SendWebRTCMessage(response);
|
|
272
|
+
}
|
|
273
|
+
SendToServer(peer, messageType, payload) {
|
|
274
|
+
const webrtcMessage = new WebRTCMessage();
|
|
275
|
+
webrtcMessage.componentId = this.component.component.id;
|
|
276
|
+
webrtcMessage.rigId = peer.message.rigId;
|
|
277
|
+
webrtcMessage.webRTCConnectionType = peer.message.webRTCConnectionType;
|
|
278
|
+
webrtcMessage.sessionId = peer.sessionId;
|
|
279
|
+
webrtcMessage.userId = peer.message.userId;
|
|
280
|
+
webrtcMessage.rigOwnerUserId = peer.message.rigOwnerUserId;
|
|
281
|
+
webrtcMessage.messageType = messageType;
|
|
282
|
+
webrtcMessage.message = payload;
|
|
283
|
+
SignalR.SendWebRTCMessage(webrtcMessage);
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------- peers
|
|
286
|
+
OnPeerStateChange(peer, state) {
|
|
287
|
+
this.Log(`session ${peer.sessionId} state: ${state}`);
|
|
288
|
+
if (peer.disconnectedTimer != null) {
|
|
289
|
+
clearTimeout(peer.disconnectedTimer);
|
|
290
|
+
peer.disconnectedTimer = null;
|
|
291
|
+
}
|
|
292
|
+
if (state == "connected") {
|
|
293
|
+
const pair = peer.pc.getSelectedCandidatePair();
|
|
294
|
+
if (pair != null) {
|
|
295
|
+
this.Log(`session ${peer.sessionId} connected via ${pair.local.type}/${pair.local.transportType} -> ${pair.remote.type} ${pair.remote.address}:${pair.remote.port}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else if (state == "disconnected") {
|
|
299
|
+
peer.disconnectedTimer = setTimeout(() => this.ClosePeer(peer, "disconnected too long"), DISCONNECTED_TIMEOUT_MS);
|
|
300
|
+
}
|
|
301
|
+
else if (state == "failed" || state == "closed") {
|
|
302
|
+
// close() from within the state callback of libdatachannel itself is not allowed: defer it
|
|
303
|
+
setImmediate(() => this.ClosePeer(peer, state));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
ClosePeer(peer, reason) {
|
|
307
|
+
if (peer.closed) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
peer.closed = true;
|
|
311
|
+
if (peer.disconnectedTimer != null) {
|
|
312
|
+
clearTimeout(peer.disconnectedTimer);
|
|
313
|
+
peer.disconnectedTimer = null;
|
|
314
|
+
}
|
|
315
|
+
if (this.peers.get(peer.sessionId) === peer) {
|
|
316
|
+
this.peers.delete(peer.sessionId);
|
|
317
|
+
}
|
|
318
|
+
this.Log(`closing session ${peer.sessionId} (${reason}) after ${peer.framesSent} frames`);
|
|
319
|
+
try {
|
|
320
|
+
peer.pc.close();
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
this.Log("close failed: " + (err?.message ?? err));
|
|
324
|
+
}
|
|
325
|
+
this.capture?.Unsubscribe();
|
|
326
|
+
}
|
|
327
|
+
OnRtcp(peer, msg) {
|
|
328
|
+
// walk the compound RTCP packet, looking for PLI (PSFB 206 / FMT 1) and FIR (PSFB 206 / FMT 4)
|
|
329
|
+
let offset = 0;
|
|
330
|
+
while (offset + 4 <= msg.length) {
|
|
331
|
+
const fmt = msg[offset] & 0x1f;
|
|
332
|
+
const packetType = msg[offset + 1];
|
|
333
|
+
const length = (msg.readUInt16BE(offset + 2) + 1) * 4;
|
|
334
|
+
if (packetType == 206 && (fmt == 1 || fmt == 4)) {
|
|
335
|
+
// the encoder cannot be forced to make a keyframe: the next one (keyframe interval) is at most ~1 s away
|
|
336
|
+
this.Log(`session ${peer.sessionId} requests a keyframe (${fmt == 1 ? "PLI" : "FIR"})`);
|
|
337
|
+
}
|
|
338
|
+
offset += length;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// ---------------------------------------------------------------- media
|
|
342
|
+
SendVideo(frame) {
|
|
343
|
+
for (const peer of this.peers.values()) {
|
|
344
|
+
if (peer.closed || !peer.videoTrack.isOpen()) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (peer.waitingForKeyframe) {
|
|
348
|
+
if (!frame.keyframe) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
peer.waitingForKeyframe = false;
|
|
352
|
+
this.Log(`first keyframe to session ${peer.sessionId} (${frame.data.length} bytes)`);
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
peer.videoConfig.timestamp = frame.timestamp;
|
|
356
|
+
peer.videoTrack.sendMessageBinary(frame.data);
|
|
357
|
+
peer.framesSent++;
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
this.Log(`video send to session ${peer.sessionId} failed: ${err?.message ?? err}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
SendAudio(frame) {
|
|
365
|
+
for (const peer of this.peers.values()) {
|
|
366
|
+
if (peer.closed || peer.audioTrack == null || peer.audioConfig == null || !peer.audioTrack.isOpen()) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
peer.audioConfig.timestamp = frame.timestamp;
|
|
371
|
+
peer.audioTrack.sendMessageBinary(frame.data);
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
this.Log(`audio send to session ${peer.sessionId} failed: ${err?.message ?? err}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// ---------------------------------------------------------------- configuration
|
|
379
|
+
GetIceServerConfig() {
|
|
380
|
+
const servers = [];
|
|
381
|
+
const stun = this.clientRTCConfigurationViewModel.stunServer ?? "";
|
|
382
|
+
if (stun != "") {
|
|
383
|
+
servers.push(`stun:${stun}`);
|
|
384
|
+
}
|
|
385
|
+
const turn = this.ParseTurnServer();
|
|
386
|
+
if (turn != null) {
|
|
387
|
+
servers.push(turn);
|
|
388
|
+
}
|
|
389
|
+
return servers;
|
|
390
|
+
}
|
|
391
|
+
// the server sends "username:credential@host:port" (the --turn format of webrtc-streamer);
|
|
392
|
+
// a TURN REST username contains a ':' itself (expiry:user), the credential (base64) does not
|
|
393
|
+
ParseTurnServer() {
|
|
394
|
+
const turn = this.clientRTCConfigurationViewModel.turnServer ?? "";
|
|
395
|
+
if (turn == "") {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
const at = turn.lastIndexOf("@");
|
|
399
|
+
const hostPort = at >= 0 ? turn.substring(at + 1) : turn;
|
|
400
|
+
const credentials = at >= 0 ? turn.substring(0, at) : "";
|
|
401
|
+
const colon = credentials.lastIndexOf(":");
|
|
402
|
+
const username = colon >= 0 ? credentials.substring(0, colon) : credentials;
|
|
403
|
+
const password = colon >= 0 ? credentials.substring(colon + 1) : "";
|
|
404
|
+
const portColon = hostPort.lastIndexOf(":");
|
|
405
|
+
const hostname = portColon >= 0 ? hostPort.substring(0, portColon) : hostPort;
|
|
406
|
+
const port = portColon >= 0 ? parseInt(hostPort.substring(portColon + 1)) : 3478;
|
|
407
|
+
return { hostname, port: isNaN(port) ? 3478 : port, username, password, relayType: "TurnUdp" };
|
|
408
|
+
}
|
|
409
|
+
GetCaptureSettings() {
|
|
410
|
+
const number = (name, fallback) => {
|
|
411
|
+
const value = parseInt(this.GetOptionalProperty(name));
|
|
412
|
+
return isNaN(value) || value <= 0 ? fallback : value;
|
|
413
|
+
};
|
|
414
|
+
const fps = number("fps", 30);
|
|
415
|
+
// webrtc-streamer took the bitrate in bps or kbps depending on the version: accept both
|
|
416
|
+
let bitrate = number("bitrate", 2000);
|
|
417
|
+
if (bitrate >= 50000) {
|
|
418
|
+
bitrate = Math.round(bitrate / 1000);
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
devicePath: this.GetOptionalProperty("devicepath"),
|
|
422
|
+
audioPath: this.GetOptionalProperty("audiopath"),
|
|
423
|
+
width: number("width", 1280),
|
|
424
|
+
height: number("height", 720),
|
|
425
|
+
fps: fps,
|
|
426
|
+
videoBitrateKbps: bitrate,
|
|
427
|
+
audioBitrateKbps: number("audiobitrate", 32),
|
|
428
|
+
inputFormat: this.GetOptionalProperty("inputformat").toLowerCase(),
|
|
429
|
+
encoder: this.GetOptionalProperty("encoder") || process.env["REMOTERIGS_H264_ENCODER"] || "h264_v4l2m2m",
|
|
430
|
+
keyframeIntervalFrames: fps,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// GetProperty logs every missing property: the optional ones are checked here first
|
|
434
|
+
GetOptionalProperty(name) {
|
|
435
|
+
if (!this.component.property.hasOwnProperty(name)) {
|
|
436
|
+
return "";
|
|
437
|
+
}
|
|
438
|
+
return (this.component.GetProperty(name) ?? "").trim();
|
|
439
|
+
}
|
|
440
|
+
GetName() {
|
|
441
|
+
return "#" + this.component.component.id + " NativeStreamer";
|
|
442
|
+
}
|
|
443
|
+
Log(message) {
|
|
444
|
+
Debug.Log(LogSettingType.Component, this.component.component.id, this.GetName(), message);
|
|
445
|
+
}
|
|
446
|
+
Error(message) {
|
|
447
|
+
Debug.Error(this.GetName(), message);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
//# sourceMappingURL=native-webrtc-streamer.js.map
|
|
@@ -121,10 +121,12 @@ export default class WebRTCStreamerWrapper {
|
|
|
121
121
|
else {
|
|
122
122
|
this.Log("webrtc-streamer not running, starting it...");
|
|
123
123
|
}
|
|
124
|
+
const stunServer = this.clientRTCConfigurationViewModel.stunServer ?? "";
|
|
124
125
|
const args = [
|
|
125
126
|
"-H", `0.0.0.0:${this.STREAMER_PORT}`,
|
|
126
|
-
"-s",
|
|
127
|
-
//"
|
|
127
|
+
// "-s<host:port>" (no space): our own STUN server, so the rig also has a public (srflx) candidate;
|
|
128
|
+
// "-s-" = no STUN
|
|
129
|
+
stunServer != "" ? `-s${stunServer}` : "-s-",
|
|
128
130
|
];
|
|
129
131
|
if (turnServer != "") {
|
|
130
132
|
args.push("--turn", turnServer);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Debug from '../common/debug.js';
|
|
2
2
|
import WebRTCStreamerWrapper from "../common/webrtc-streamer-wrapper.js";
|
|
3
|
+
import NativeWebRTCStreamer from "../common/native/native-webrtc-streamer.js";
|
|
3
4
|
import Code from "../common/code.js";
|
|
4
5
|
import StatusNumber from './status/status-number.js';
|
|
5
6
|
import StatusState from './status/status-state.js';
|
|
@@ -120,9 +121,17 @@ export default class Component {
|
|
|
120
121
|
var devicePath = this.GetProperty("devicepath");
|
|
121
122
|
if (devicePath != undefined) {
|
|
122
123
|
if (this.clientRTCConfigurationViewModel != undefined) {
|
|
123
|
-
|
|
124
|
+
// "native" = node-datachannel + ffmpeg, anything else = the webrtc-streamer binary;
|
|
125
|
+
// per camera with the property 'streamer', or for the whole Pi with REMOTERIGS_STREAMER
|
|
126
|
+
var streamerType = (this.property.hasOwnProperty("streamer") ? this.GetProperty("streamer") : "") || process.env["REMOTERIGS_STREAMER"] || "webrtc-streamer";
|
|
127
|
+
if (streamerType.toLowerCase() == "native") {
|
|
128
|
+
this.streamer = new NativeWebRTCStreamer(this, this.clientRTCConfigurationViewModel, this.rig.rig.videoType);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
this.streamer = new WebRTCStreamerWrapper(this, this.clientRTCConfigurationViewModel, this.rig.rig.videoType);
|
|
132
|
+
}
|
|
124
133
|
this.streamer.Start().catch(console.error);
|
|
125
|
-
this.Log("init webcam streamer success: " + devicePath);
|
|
134
|
+
this.Log("init webcam streamer (" + streamerType + ") success: " + devicePath);
|
|
126
135
|
}
|
|
127
136
|
else {
|
|
128
137
|
this.Log("init webcam FAILED; missing clientRTCConfigurationViewModel");
|