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.14",
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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=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
- //"--stun", this.clientRTCConfigurationViewModel.stunServer,
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
- this.streamer = new WebRTCStreamerWrapper(this, this.clientRTCConfigurationViewModel, this.rig.rig.videoType);
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");