remoterigs-raspberrypi 0.1.16 → 0.1.18

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.16",
4
+ "version": "0.1.18",
5
5
  "main": "./index.js",
6
6
  "files": [
7
7
  "index.js",
@@ -14,7 +14,7 @@
14
14
  "private": false,
15
15
  "dependencies": {
16
16
  "@microsoft/signalr": "^8.0.7",
17
- "axios": "^1.13.2",
17
+ "axios": "^1.21.0",
18
18
  "node-datachannel": "^0.33.6",
19
19
  "pigpio": "^3.3.1"
20
20
  }
@@ -1,4 +1,5 @@
1
1
  import { spawn, spawnSync } from 'child_process';
2
+ import fs from 'fs';
2
3
  import { EventEmitter } from 'events';
3
4
  import dgram from 'dgram';
4
5
  import { performance } from 'perf_hooks';
@@ -42,9 +43,14 @@ export default class CaptureSource extends EventEmitter {
42
43
  // audio timestamps: ffmpeg RTP timestamp + offset = wall clock in 48 kHz
43
44
  this.audioTimestampOffset = null;
44
45
  this.resolvedAudioDevice = null;
46
+ this.resolvedVideoDevice = null;
47
+ // restart delay grows while a process keeps failing (camera unplugged), back to the minimum after a success
48
+ this.videoFailures = 0;
49
+ this.audioFailures = 0;
45
50
  // keep the camera open a bit after the last viewer left, a reconnect (page reload) is then instant
46
51
  this.STOP_DELAY_MS = 15000;
47
52
  this.RESTART_DELAY_MS = 2000;
53
+ this.MAX_RESTART_DELAY_MS = 30000;
48
54
  this.setMaxListeners(100);
49
55
  }
50
56
  get HasAudio() {
@@ -110,17 +116,18 @@ export default class CaptureSource extends EventEmitter {
110
116
  this.currentAccessUnit = [];
111
117
  this.audioTimestampOffset = null;
112
118
  }
113
- ScheduleRestart(what, start) {
119
+ ScheduleRestart(what, failures, start) {
114
120
  if (!this.running) {
115
121
  return;
116
122
  }
117
- this.log(`${what} stopped, restarting in ${this.RESTART_DELAY_MS} ms`);
123
+ const delay = Math.min(this.MAX_RESTART_DELAY_MS, this.RESTART_DELAY_MS * Math.pow(2, Math.max(0, failures - 1)));
124
+ this.log(`${what} stopped, restarting in ${delay} ms`);
118
125
  const timer = setTimeout(() => {
119
126
  this.restartTimers.delete(timer);
120
127
  if (this.running) {
121
128
  start();
122
129
  }
123
- }, this.RESTART_DELAY_MS);
130
+ }, delay);
124
131
  this.restartTimers.add(timer);
125
132
  }
126
133
  // ---------------------------------------------------------------- video
@@ -148,7 +155,7 @@ export default class CaptureSource extends EventEmitter {
148
155
  if (s.fps > 0) {
149
156
  args.push("-framerate", String(s.fps));
150
157
  }
151
- args.push("-i", s.devicePath);
158
+ args.push("-i", this.resolvedVideoDevice ?? s.devicePath);
152
159
  }
153
160
  args.push("-an");
154
161
  if (inputFormat == "h264") {
@@ -173,6 +180,10 @@ export default class CaptureSource extends EventEmitter {
173
180
  return args;
174
181
  }
175
182
  StartVideo() {
183
+ if (!this.settings.devicePath.startsWith("lavfi:") && (this.resolvedVideoDevice == null || this.videoFailures > 0)) {
184
+ // (again) after a failure: a USB camera that was unplugged can come back under another /dev/videoN
185
+ this.resolvedVideoDevice = this.ResolveVideoDevice(this.settings.devicePath);
186
+ }
176
187
  const args = this.BuildVideoArgs();
177
188
  const startedAt = Date.now();
178
189
  let lastStderr = "";
@@ -201,7 +212,8 @@ export default class CaptureSource extends EventEmitter {
201
212
  this.log("no frames with input format mjpeg, falling back to the camera default format");
202
213
  this.useDefaultInputFormat = true;
203
214
  }
204
- this.ScheduleRestart("video", () => this.StartVideo());
215
+ this.videoFailures = this.framesEmitted > 0 ? 1 : this.videoFailures + 1;
216
+ this.ScheduleRestart("video", this.videoFailures, () => this.StartVideo());
205
217
  });
206
218
  }
207
219
  OnVideoData(chunk) {
@@ -274,16 +286,110 @@ export default class CaptureSource extends EventEmitter {
274
286
  keyframe: keyframe,
275
287
  };
276
288
  this.framesEmitted++;
289
+ if (this.framesEmitted == 1) {
290
+ this.log(`first frame from ${this.resolvedVideoDevice ?? this.settings.devicePath} (${frame.data.length} bytes, keyframe: ${frame.keyframe})`);
291
+ this.videoFailures = 0;
292
+ }
277
293
  this.emit("video", frame);
278
294
  }
279
- // ---------------------------------------------------------------- audio
280
- ResolveAudioDevice(audioPath) {
281
- let path = audioPath.trim();
282
- // webrtc-streamer style: audiocap://1
283
- const capMatch = /^audiocap:\/\/(\d+)$/.exec(path);
295
+ // the video capture devices in the same order as webrtc-streamer (libwebrtc) numbers them:
296
+ // /dev/video0..63 that have the VIDEO_CAPTURE capability (so no metadata nodes, no codec devices)
297
+ ListVideoCaptureDevices() {
298
+ const devices = [];
299
+ for (let n = 0; n < 64; n++) {
300
+ const device = `/dev/video${n}`;
301
+ if (!fs.existsSync(device)) {
302
+ continue;
303
+ }
304
+ const result = spawnSync("v4l2-ctl", ["-d", device, "--info"], { encoding: "utf8", timeout: 2000 });
305
+ if (result.error != null) {
306
+ // no v4l2-ctl (package v4l-utils): use the name from sysfs, skip the Pi's own codec / ISP devices
307
+ const name = this.ReadSysfs(`/sys/class/video4linux/video${n}/name`);
308
+ const index = this.ReadSysfs(`/sys/class/video4linux/video${n}/index`);
309
+ if (index == "0" && !/^(bcm2835|rpi-|pispbe|rp1-)/.test(name)) {
310
+ devices.push({ device, name, driver: "?" });
311
+ }
312
+ continue;
313
+ }
314
+ const output = result.stdout ?? "";
315
+ const deviceCaps = /Device Caps\s*:\s*0x([0-9a-fA-F]+)/.exec(output);
316
+ const capabilities = /Capabilities\s*:\s*0x([0-9a-fA-F]+)/.exec(output);
317
+ const caps = parseInt((deviceCaps ?? capabilities)?.[1] ?? "0", 16);
318
+ // V4L2_CAP_VIDEO_CAPTURE = 0x00000001
319
+ if ((caps & 0x1) != 0) {
320
+ devices.push({
321
+ device,
322
+ name: /Card type\s*:\s*(.*)/.exec(output)?.[1]?.trim() ?? "",
323
+ driver: /Driver name\s*:\s*(.*)/.exec(output)?.[1]?.trim() ?? "",
324
+ });
325
+ }
326
+ }
327
+ return devices;
328
+ }
329
+ ReadSysfs(path) {
330
+ try {
331
+ return fs.readFileSync(path, "utf8").trim();
332
+ }
333
+ catch {
334
+ return "";
335
+ }
336
+ }
337
+ ResolveVideoDevice(devicePath) {
338
+ const path = devicePath.trim();
339
+ // a real device path: /dev/video0, /dev/v4l/by-id/usb-...-video-index0
340
+ if (path.startsWith("/dev/")) {
341
+ return path;
342
+ }
343
+ const devices = this.ListVideoCaptureDevices();
344
+ this.log("video capture devices: " + (devices.length == 0 ? "none" : devices.map((d, i) => `[${i}] ${d.device} '${d.name}' (${d.driver})`).join(" | ")));
345
+ // webrtc-streamer style: videocap://N = the N-th capture device
346
+ const capMatch = /^videocap:\/\/(\d+)$/.exec(path);
284
347
  if (capMatch) {
285
- path = capMatch[1];
348
+ const index = parseInt(capMatch[1]);
349
+ // a Pi internal device (ISP / codec) is never the camera that was meant: then take the USB webcam
350
+ const isPiInternal = (d) => /^(bcm2835|rpi-|pispbe|rp1-)/.test(d.driver) || /^(bcm2835|rpi-|pispbe|rp1-)/.test(d.name);
351
+ if (index < devices.length && !isPiInternal(devices[index])) {
352
+ this.log(`video device '${devicePath}' -> ${devices[index].device} '${devices[index].name}'`);
353
+ return devices[index].device;
354
+ }
355
+ // fewer devices than webrtc-streamer saw: the USB webcam (uvcvideo) is the best guess
356
+ const uvc = devices.find(d => d.driver == "uvcvideo") ?? devices.find(d => !isPiInternal(d));
357
+ if (uvc != null) {
358
+ this.log(`video device '${devicePath}': capture device ${index} is not a camera, using ${uvc.device} '${uvc.name}'`);
359
+ return uvc.device;
360
+ }
361
+ return "/dev/video0";
362
+ }
363
+ // a (part of a) camera name, e.g. "C920"
364
+ const match = devices.find(d => d.name.toLowerCase().includes(path.toLowerCase()));
365
+ if (match) {
366
+ this.log(`video device '${devicePath}' -> ${match.device} '${match.name}'`);
367
+ return match.device;
368
+ }
369
+ this.log(`video device '${devicePath}' not found, using it as it is`);
370
+ return path;
371
+ }
372
+ // ---------------------------------------------------------------- audio
373
+ // capture devices from 'arecord -l', in that order:
374
+ // card 1: C920 [HD Pro Webcam C920], device 0: USB Audio [USB Audio]
375
+ ListAudioCaptureDevices() {
376
+ const devices = [];
377
+ try {
378
+ const result = spawnSync("arecord", ["-l"], { encoding: "utf8", timeout: 5000 });
379
+ for (const line of (result.stdout ?? "").split("\n")) {
380
+ const m = /^card (\d+): (\S+) \[([^\]]*)\], device (\d+)/.exec(line);
381
+ if (m) {
382
+ devices.push({ device: `plughw:CARD=${m[2]},DEV=${m[4]}`, description: line.trim() });
383
+ }
384
+ }
286
385
  }
386
+ catch (err) {
387
+ this.log("arecord -l failed: " + err);
388
+ }
389
+ return devices;
390
+ }
391
+ ResolveAudioDevice(audioPath) {
392
+ const path = audioPath.trim();
287
393
  // ALSA device names (and the lavfi test source) can be used as they are
288
394
  if (/^(lavfi|hw|plughw|default|sysdefault|dsnoop|front):?/.test(path)) {
289
395
  return path;
@@ -292,20 +398,22 @@ export default class CaptureSource extends EventEmitter {
292
398
  if (/^\d+$/.test(path)) {
293
399
  return `plughw:${path},0`;
294
400
  }
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
- }
401
+ const devices = this.ListAudioCaptureDevices();
402
+ this.log("audio capture devices: " + (devices.length == 0 ? "none" : devices.map((d, i) => `[${i}] ${d.description}`).join(" | ")));
403
+ // webrtc-streamer style: audiocap://N = the N-th capture device (not card N: card 0 of a Pi is HDMI, without input)
404
+ const capMatch = /^audiocap:\/\/(\d+)$/.exec(path);
405
+ if (capMatch) {
406
+ const index = parseInt(capMatch[1]);
407
+ if (index < devices.length) {
408
+ return devices[index].device;
305
409
  }
410
+ this.log(`audio device '${audioPath}': there is no capture device ${index}`);
411
+ return devices.length > 0 ? devices[0].device : "default";
306
412
  }
307
- catch (err) {
308
- this.log("arecord -l failed: " + err);
413
+ // a (part of a) card name, e.g. "C920" or "USB Camera"
414
+ const match = devices.find(d => d.description.toLowerCase().includes(path.toLowerCase()));
415
+ if (match) {
416
+ return match.device;
309
417
  }
310
418
  this.log(`audio device '${audioPath}' not found in 'arecord -l', using it as ALSA device name`);
311
419
  return path;
@@ -343,6 +451,7 @@ export default class CaptureSource extends EventEmitter {
343
451
  `rtp://127.0.0.1:${port}?rtcpport=${port}&pkt_size=1200`,
344
452
  ];
345
453
  this.log("starting audio: ffmpeg " + args.join(" "));
454
+ const audioStartedAt = Date.now();
346
455
  const proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
347
456
  this.audioProcess = proc;
348
457
  this.audioTimestampOffset = null;
@@ -358,7 +467,10 @@ export default class CaptureSource extends EventEmitter {
358
467
  }
359
468
  this.audioProcess = null;
360
469
  this.log(`ffmpeg audio exited (code ${code}, signal ${signal})`);
361
- this.ScheduleRestart("audio", () => { this.StartAudio().catch(err => this.log("audio restart failed: " + err)); });
470
+ this.audioFailures = Date.now() - audioStartedAt > 10000 ? 1 : this.audioFailures + 1;
471
+ // a device that did not work: look it up again on the next start
472
+ this.resolvedAudioDevice = null;
473
+ this.ScheduleRestart("audio", this.audioFailures, () => { this.StartAudio().catch(err => this.log("audio restart failed: " + err)); });
362
474
  });
363
475
  }
364
476
  OnAudioRtp(packet) {