@apocaliss92/scrypted-reolink-native 0.5.45 → 0.5.46

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/dist/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apocaliss92/scrypted-reolink-native",
3
- "version": "0.5.45",
3
+ "version": "0.5.46",
4
4
  "description": "Use any reolink camera with Scrypted, even older/unsupported models without HTTP protocol support",
5
5
  "author": "@apocaliss92",
6
6
  "license": "Apache",
@@ -192,6 +192,10 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
192
192
  private readonly reconnectBackoffMs: number = 2000; // 2 seconds minimum between reconnects
193
193
  private eventSubscriptionActive: boolean = false;
194
194
  private lastEventTime: number = 0;
195
+ // Timestamp of the last triggered (silence-based) event restart. Used to
196
+ // rate-limit full unsub/resub cycles so a persistently-quiet camera can't be
197
+ // restarted on every check window.
198
+ private lastEventRestartTime: number = 0;
195
199
  private currentWrappedEventHandler?: (ev: ReolinkSimpleEvent) => void;
196
200
  private subscribeToEventsPromise?: Promise<void>;
197
201
  private pingInterval?: NodeJS.Timeout;
@@ -940,20 +944,40 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
940
944
  const timeSinceLastEvent = now - this.lastEventTime;
941
945
  const tenMinutesMs = 10 * 60 * 1000;
942
946
 
947
+ // Rate-limit triggered restarts: never restart more than once per
948
+ // 10 minutes. A persistently-quiet camera otherwise gets a full
949
+ // unsub/resub cycle on every qualifying check window.
950
+ const canRestart =
951
+ now - this.lastEventRestartTime >= tenMinutesMs;
952
+
943
953
  if (this.lastEventTime > 0 && timeSinceLastEvent > tenMinutesMs) {
944
- logger.log(
945
- `No events received in the last ${Math.round(timeSinceLastEvent / 60_000)} minutes, performing full plugin-level event restart`,
946
- );
947
- await this.unsubscribeFromEvents(true);
948
- await this.subscribeToEvents(true);
949
- } else if (this.lastEventTime === 0) {
950
- const timeSinceSubscription = now - (this.connectionTime || now);
951
- if (timeSinceSubscription > tenMinutesMs) {
954
+ if (!canRestart) {
955
+ logger.debug(
956
+ "Event check: silence detected but skipping restart (backoff: last restart < 10min ago)",
957
+ );
958
+ } else {
952
959
  logger.log(
953
- `No events received since subscription (${Math.round(timeSinceSubscription / 60_000)} minutes ago), performing full plugin-level event restart`,
960
+ `No events received in the last ${Math.round(timeSinceLastEvent / 60_000)} minutes, performing full plugin-level event restart`,
954
961
  );
955
962
  await this.unsubscribeFromEvents(true);
956
963
  await this.subscribeToEvents(true);
964
+ this.lastEventRestartTime = Date.now();
965
+ }
966
+ } else if (this.lastEventTime === 0) {
967
+ const timeSinceSubscription = now - (this.connectionTime || now);
968
+ if (timeSinceSubscription > tenMinutesMs) {
969
+ if (!canRestart) {
970
+ logger.debug(
971
+ "Event check: no events since subscription but skipping restart (backoff: last restart < 10min ago)",
972
+ );
973
+ } else {
974
+ logger.log(
975
+ `No events received since subscription (${Math.round(timeSinceSubscription / 60_000)} minutes ago), performing full plugin-level event restart`,
976
+ );
977
+ await this.unsubscribeFromEvents(true);
978
+ await this.subscribeToEvents(true);
979
+ this.lastEventRestartTime = Date.now();
980
+ }
957
981
  }
958
982
  }
959
983
  } catch (e) {
package/src/camera.ts CHANGED
@@ -495,6 +495,18 @@ export class ReolinkCamera
495
495
  defaultValue: 60,
496
496
  hide: true,
497
497
  },
498
+ statusPollIntervalSeconds: {
499
+ title: "Accessory Status Poll Interval (seconds)",
500
+ subgroup: "Advanced",
501
+ description:
502
+ "How often to poll accessory device state (siren, floodlight, PIR, autotracking, chime) for wired cameras. 0 disables polling (push events still update state). Default: 60.",
503
+ type: "number",
504
+ defaultValue: 60,
505
+ hide: true,
506
+ onPut: async () => {
507
+ this.restartStatusPoll();
508
+ },
509
+ },
498
510
  diagnosticsOutputPath: {
499
511
  title: "Diagnostics Output Path",
500
512
  subgroup: "Diagnostics",
@@ -593,6 +605,16 @@ export class ReolinkCamera
593
605
  this.updateVideoClipsAutoLoad();
594
606
  },
595
607
  },
608
+ videoclipsHighWaterMark: {
609
+ // Internal: epoch ms of the most recent clip fully processed by the
610
+ // auto-load run. Subsequent runs start from this mark (minus a small
611
+ // overlap) instead of re-scanning the whole preload window every time.
612
+ // 0 means "no successful run yet" (full backfill).
613
+ type: "number",
614
+ defaultValue: 0,
615
+ hide: true,
616
+ readonly: true,
617
+ },
596
618
  clearVideoclipsCache: {
597
619
  title: "Clear All Video Clips Cache",
598
620
  group: "Videoclips",
@@ -1528,6 +1550,10 @@ export class ReolinkCamera
1528
1550
  // Recreate the cache directory for future use
1529
1551
  await fs.promises.mkdir(cacheDir, { recursive: true });
1530
1552
 
1553
+ // Reset the auto-load high-water mark so the next run does a full
1554
+ // backfill over the configured preload window (the cache is now empty).
1555
+ this.storageSettings.values.videoclipsHighWaterMark = 0;
1556
+
1531
1557
  const sizeMB = (totalSize / (1024 * 1024)).toFixed(2);
1532
1558
  logger.log(
1533
1559
  `[VideoClips] Cache cleared: ${fileCount} files, ${sizeMB} MB freed. Cache directory recreated.`,
@@ -1568,8 +1594,9 @@ export class ReolinkCamera
1568
1594
  `Starting video clips auto-load: checking every ${videoclipsRegularChecks} minutes`,
1569
1595
  );
1570
1596
 
1571
- // Run immediately on start
1572
- this.loadTodayVideoClipsAndThumbnails();
1597
+ // Stagger the initial run so many cameras don't hit their recordings
1598
+ // listing at the same instant on plugin startup.
1599
+ setTimeout(() => this.loadTodayVideoClipsAndThumbnails(), Math.random() * 60_000);
1573
1600
 
1574
1601
  // Then run at regular intervals
1575
1602
  this.videoClipsAutoLoadInterval = setInterval(() => {
@@ -1595,9 +1622,6 @@ export class ReolinkCamera
1595
1622
  try {
1596
1623
  const daysToPreload =
1597
1624
  this.storageSettings.values.videoclipsDaysToPreload ?? 1;
1598
- logger.log(
1599
- `Auto-loading video clips and thumbnails for the last ${daysToPreload} day(s)...`,
1600
- );
1601
1625
 
1602
1626
  // Get date range (start of N days ago to now)
1603
1627
  const now = new Date();
@@ -1606,21 +1630,42 @@ export class ReolinkCamera
1606
1630
  startDate.setUTCHours(0, 0, 0, 0);
1607
1631
  startDate.setUTCMinutes(0, 0, 0);
1608
1632
 
1609
- // Fetch video clips for the specified number of days
1633
+ // Incremental load: on the first run (mark == 0) keep the full backfill
1634
+ // over the preload window; on subsequent runs start from the high-water
1635
+ // mark (minus a 5min overlap so we never miss a clip that straddled the
1636
+ // previous run boundary), but never earlier than the preload window.
1637
+ const highWaterMark =
1638
+ this.storageSettings.values.videoclipsHighWaterMark ?? 0;
1639
+ const OVERLAP_MS = 5 * 60 * 1000;
1640
+ const windowStartMs = startDate.getTime();
1641
+ const effectiveStartMs =
1642
+ highWaterMark > 0
1643
+ ? Math.max(windowStartMs, highWaterMark - OVERLAP_MS)
1644
+ : windowStartMs;
1645
+
1646
+ logger.log(
1647
+ `Auto-loading video clips and thumbnails (${daysToPreload} day window, ` +
1648
+ `${highWaterMark > 0 ? "incremental from high-water mark" : "full backfill"})...`,
1649
+ );
1650
+
1651
+ // Fetch video clips for the (possibly narrowed) range
1610
1652
  const clips = await this.getVideoClips({
1611
- startTime: startDate.getTime(),
1653
+ startTime: effectiveStartMs,
1612
1654
  endTime: now.getTime(),
1613
1655
  });
1614
1656
 
1615
- logger.log(
1616
- `Found ${clips.length} video clips for the last ${daysToPreload} day(s)`,
1617
- );
1657
+ logger.log(`Found ${clips.length} video clips to process`);
1618
1658
 
1619
1659
  const downloadVideoclipsLocally =
1620
1660
  this.storageSettings.values.downloadVideoclipsLocally ?? false;
1621
1661
 
1622
1662
  // Track processed clips to avoid duplicate calls to the camera
1623
1663
  const processedClips = new Set<string>();
1664
+ // Most-recent clip start time fully processed this run; advances the mark.
1665
+ let maxProcessedStart = highWaterMark;
1666
+ // Only advance the high-water mark if the whole loop completes cleanly.
1667
+ let runHadError = false;
1668
+ const MIN_THUMB_CACHE_BYTES = 512;
1624
1669
 
1625
1670
  // Download videos first (if enabled), then thumbnails for each clip
1626
1671
  for (const clip of clips) {
@@ -1631,10 +1676,23 @@ export class ReolinkCamera
1631
1676
  }
1632
1677
  processedClips.add(clip.id);
1633
1678
 
1679
+ // Cheap pre-check: if the thumbnail is already cached and non-trivial,
1680
+ // skip the thumbnail generation entirely. The video file cache is
1681
+ // request/HLS-driven (not written by getVideoClip here), so it is NOT
1682
+ // used as a skip signal.
1683
+ let thumbnailCached = false;
1684
+ try {
1685
+ const thumbPath = this.getThumbnailCachePath(clip.id);
1686
+ const stats = await fs.promises.stat(thumbPath);
1687
+ thumbnailCached = stats.size >= MIN_THUMB_CACHE_BYTES;
1688
+ } catch {
1689
+ // Not cached (or unreadable) — fall through to generate it.
1690
+ }
1691
+
1634
1692
  try {
1635
- // If downloadVideoclipsLocally is enabled, download the video clip first
1636
- // This allows the thumbnail to use the local file instead of calling the camera
1637
- if (downloadVideoclipsLocally) {
1693
+ // If downloadVideoclipsLocally is enabled, build the video webhook URL.
1694
+ // This is cheap (no camera round-trip); only skip the thumbnail below.
1695
+ if (downloadVideoclipsLocally && !thumbnailCached) {
1638
1696
  try {
1639
1697
  // Call getVideoClip to trigger download and caching
1640
1698
  await this.getVideoClip(clip.id);
@@ -1647,18 +1705,29 @@ export class ReolinkCamera
1647
1705
  }
1648
1706
  }
1649
1707
 
1650
- // Then get the thumbnail - this will use the local video file if available
1651
- // or call the camera if the video wasn't downloaded
1652
- try {
1653
- await this.getVideoClipThumbnail(clip.id);
1654
- logger.debug(`Downloaded thumbnail for clip: ${clip.id}`);
1655
- } catch (e) {
1656
- logger.warn(
1657
- `Failed to load thumbnail for clip ${clip.id}:`,
1658
- e instanceof Error ? e.message : String(e),
1659
- );
1708
+ if (thumbnailCached) {
1709
+ logger.debug(`Skipping already cached thumbnail: ${clip.id}`);
1710
+ } else {
1711
+ // Then get the thumbnail - this will use the local video file if
1712
+ // available or call the camera if the video wasn't downloaded
1713
+ try {
1714
+ await this.getVideoClipThumbnail(clip.id);
1715
+ logger.debug(`Downloaded thumbnail for clip: ${clip.id}`);
1716
+ } catch (e) {
1717
+ runHadError = true;
1718
+ logger.warn(
1719
+ `Failed to load thumbnail for clip ${clip.id}:`,
1720
+ e instanceof Error ? e.message : String(e),
1721
+ );
1722
+ }
1723
+ }
1724
+
1725
+ // Track the most-recent successfully-handled clip for the mark.
1726
+ if (typeof clip.startTime === "number") {
1727
+ maxProcessedStart = Math.max(maxProcessedStart, clip.startTime);
1660
1728
  }
1661
1729
  } catch (e) {
1730
+ runHadError = true;
1662
1731
  logger.warn(
1663
1732
  `Error processing clip ${clip.id}:`,
1664
1733
  e instanceof Error ? e.message : String(e),
@@ -1666,6 +1735,12 @@ export class ReolinkCamera
1666
1735
  }
1667
1736
  }
1668
1737
 
1738
+ // Advance the high-water mark only after a fully clean run, so a transient
1739
+ // failure forces a re-scan of the same window on the next pass.
1740
+ if (!runHadError && maxProcessedStart > highWaterMark) {
1741
+ this.storageSettings.values.videoclipsHighWaterMark = maxProcessedStart;
1742
+ }
1743
+
1669
1744
  logger.log(`Completed auto-loading video clips and thumbnails`);
1670
1745
  } catch (e) {
1671
1746
  logger.error(
@@ -2277,7 +2352,8 @@ export class ReolinkCamera
2277
2352
  const isDispatchEnabled = this.isEventDispatchEnabled();
2278
2353
 
2279
2354
  logger.debug(
2280
- `Baichuan event on camera (dispatch enabled: ${isDispatchEnabled}): ${JSON.stringify(ev)}`,
2355
+ `Baichuan event on camera (dispatch enabled: ${isDispatchEnabled}):`,
2356
+ ev,
2281
2357
  );
2282
2358
 
2283
2359
  if (!isDispatchEnabled) {
@@ -3235,27 +3311,34 @@ export class ReolinkCamera
3235
3311
  async getDevice(nativeId: string): Promise<any> {
3236
3312
  if (nativeId.endsWith(motionSirenSuffix)) {
3237
3313
  this.motionSiren ||= new ReolinkCameraMotionSiren(this, nativeId);
3314
+ this.restartStatusPoll();
3238
3315
  return this.motionSiren;
3239
3316
  } else if (nativeId.endsWith(sirenSuffix)) {
3240
3317
  this.siren ||= new ReolinkCameraSiren(this, nativeId);
3318
+ this.restartStatusPoll();
3241
3319
  return this.siren;
3242
3320
  } else if (nativeId.endsWith(motionFloodlightSuffix)) {
3243
3321
  this.motionFloodlight ||= new ReolinkCameraMotionFloodlight(
3244
3322
  this,
3245
3323
  nativeId,
3246
3324
  );
3325
+ this.restartStatusPoll();
3247
3326
  return this.motionFloodlight;
3248
3327
  } else if (nativeId.endsWith(floodlightSuffix)) {
3249
3328
  this.floodlight ||= new ReolinkCameraFloodlight(this, nativeId);
3329
+ this.restartStatusPoll();
3250
3330
  return this.floodlight;
3251
3331
  } else if (nativeId.endsWith(pirSuffix)) {
3252
3332
  this.pirSensor ||= new ReolinkCameraPirSensor(this, nativeId);
3333
+ this.restartStatusPoll();
3253
3334
  return this.pirSensor;
3254
3335
  } else if (nativeId.endsWith(autotrackingSuffix)) {
3255
3336
  this.autotracking ||= new ReolinkCameraAutotracking(this, nativeId);
3337
+ this.restartStatusPoll();
3256
3338
  return this.autotracking;
3257
3339
  } else if (nativeId.endsWith(chimeSuffix)) {
3258
3340
  this.chime ||= new ReolinkCameraChime(this, nativeId);
3341
+ this.restartStatusPoll();
3259
3342
  return this.chime;
3260
3343
  }
3261
3344
  }
@@ -3896,7 +3979,7 @@ export class ReolinkCamera
3896
3979
  hasPlugin: !!this.plugin,
3897
3980
  }),
3898
3981
  );
3899
- logger.debug(`${JSON.stringify(device)}`);
3982
+ logger.debug(device);
3900
3983
  } catch (e) {
3901
3984
  logger.error(
3902
3985
  "Failed to update device interfaces",
@@ -4427,7 +4510,7 @@ export class ReolinkCamera
4427
4510
  e?.message || String(e),
4428
4511
  );
4429
4512
  }
4430
- }, 5_000);
4513
+ }, 30_000);
4431
4514
  }
4432
4515
 
4433
4516
  // Update battery and snapshot every N minutes
@@ -4445,21 +4528,74 @@ export class ReolinkCamera
4445
4528
  }, updateIntervalMs);
4446
4529
 
4447
4530
  logger.log(
4448
- `Periodic tasks started: sleep check every 5s, battery update every ${batteryUpdateIntervalMinutes} minutes`,
4531
+ `Periodic tasks started: sleep check every 30s, battery update every ${batteryUpdateIntervalMinutes} minutes`,
4449
4532
  );
4450
4533
  } else {
4451
- this.statusPollTimer = setInterval(async () => {
4452
- try {
4453
- await this.alignAuxDevicesState();
4454
- } catch (e) {
4455
- logger.error(
4456
- "Error aligning auxiliary devices state:",
4457
- e?.message || String(e),
4458
- );
4459
- }
4460
- }, 10_000);
4534
+ this.restartStatusPoll();
4535
+ }
4536
+ }
4537
+
4538
+ /**
4539
+ * (Re)schedules the accessory status poll for wired cameras. The poll only
4540
+ * runs when (a) statusPollIntervalSeconds > 0 and (b) at least one accessory
4541
+ * device is actually instantiated — otherwise alignAuxDevicesState() would do
4542
+ * an ensureClient() + getAbilities() round-trip for nothing on every tick.
4543
+ * Called from startPeriodicTasks(), from the setting onPut, and after an
4544
+ * accessory child device is created (so late-created accessories get polled).
4545
+ */
4546
+ restartStatusPoll(): void {
4547
+ const logger = this.getBaichuanLogger();
4548
+
4549
+ this.statusPollTimer && clearInterval(this.statusPollTimer);
4550
+ this.statusPollTimer = undefined;
4551
+
4552
+ // Only relevant for wired cameras; battery cameras align on wake.
4553
+ if (this.isBattery) return;
4461
4554
 
4462
- logger.log("Periodic tasks started: status poll every 10s");
4555
+ const { statusPollIntervalSeconds = 60 } = this.storageSettings.values;
4556
+ if (!statusPollIntervalSeconds || statusPollIntervalSeconds <= 0) {
4557
+ logger.log("Accessory status poll disabled (interval = 0)");
4558
+ return;
4559
+ }
4560
+
4561
+ const hasAnyAccessory = (): boolean =>
4562
+ !!(
4563
+ this.motionSiren ||
4564
+ this.siren ||
4565
+ this.motionFloodlight ||
4566
+ this.floodlight ||
4567
+ this.pirSensor ||
4568
+ this.autotracking ||
4569
+ this.chime
4570
+ );
4571
+
4572
+ if (!hasAnyAccessory()) {
4573
+ // Nothing to poll yet; restartStatusPoll() is re-invoked when an
4574
+ // accessory device is created in getDevice().
4575
+ return;
4463
4576
  }
4577
+
4578
+ const intervalMs = statusPollIntervalSeconds * 1000;
4579
+
4580
+ const run = async () => {
4581
+ // Guard inside the callback: accessories may be released between ticks.
4582
+ if (!hasAnyAccessory()) return;
4583
+ try {
4584
+ await this.alignAuxDevicesState();
4585
+ } catch (e) {
4586
+ logger.error(
4587
+ "Error aligning auxiliary devices state:",
4588
+ e?.message || String(e),
4589
+ );
4590
+ }
4591
+ };
4592
+
4593
+ // Stagger the first tick so many cameras don't poll in lockstep.
4594
+ setTimeout(run, Math.random() * intervalMs);
4595
+ this.statusPollTimer = setInterval(run, intervalMs);
4596
+
4597
+ logger.log(
4598
+ `Periodic tasks started: status poll every ${statusPollIntervalSeconds}s`,
4599
+ );
4464
4600
  }
4465
4601
  }
package/src/intercom.ts CHANGED
@@ -57,7 +57,13 @@ export class ReolinkBaichuanIntercom {
57
57
  private maxBacklogMs = DEFAULT_MAX_BACKLOG_MS;
58
58
  private maxBacklogBytes: number | undefined;
59
59
 
60
- private pcmBuffer: Buffer = Buffer.alloc(0);
60
+ // PCM backlog held as a queue of chunks instead of a single growing Buffer,
61
+ // so the hot enqueue path no longer does a Buffer.concat per audio chunk.
62
+ // `pcmHeadOffset` is the number of bytes already consumed from pcmChunks[0];
63
+ // `pcmQueuedBytes` is the logical (unconsumed) byte length of the queue.
64
+ private pcmChunks: Buffer[] = [];
65
+ private pcmHeadOffset = 0;
66
+ private pcmQueuedBytes = 0;
61
67
 
62
68
  private pumping = false;
63
69
  private pumpPromise: Promise<void> | undefined;
@@ -134,7 +140,7 @@ export class ReolinkBaichuanIntercom {
134
140
  });
135
141
 
136
142
  this.session = session;
137
- this.pcmBuffer = Buffer.alloc(0);
143
+ this.resetPcmQueue();
138
144
  this.pumping = false;
139
145
  this.pumpPromise = undefined;
140
146
 
@@ -280,7 +286,7 @@ export class ReolinkBaichuanIntercom {
280
286
  const session = this.session;
281
287
  this.session = undefined;
282
288
 
283
- this.pcmBuffer = Buffer.alloc(0);
289
+ this.resetPcmQueue();
284
290
 
285
291
  const sleepMs = async (ms: number) =>
286
292
  new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -329,6 +335,62 @@ export class ReolinkBaichuanIntercom {
329
335
  return this.stopping;
330
336
  }
331
337
 
338
+ /** Reset the PCM backlog queue. */
339
+ private resetPcmQueue(): void {
340
+ this.pcmChunks = [];
341
+ this.pcmHeadOffset = 0;
342
+ this.pcmQueuedBytes = 0;
343
+ }
344
+
345
+ /**
346
+ * Drop the oldest `dropBytes` bytes from the front of the queue, advancing
347
+ * the head offset and discarding fully-consumed chunks. Used by the backlog
348
+ * clamp; preserves the same "drop oldest samples" semantics as the previous
349
+ * single-buffer `subarray(length - keep)`.
350
+ */
351
+ private dropOldestPcm(dropBytes: number): void {
352
+ let remaining = dropBytes;
353
+ while (remaining > 0 && this.pcmChunks.length) {
354
+ const head = this.pcmChunks[0];
355
+ const avail = head.length - this.pcmHeadOffset;
356
+ if (avail <= remaining) {
357
+ remaining -= avail;
358
+ this.pcmChunks.shift();
359
+ this.pcmHeadOffset = 0;
360
+ } else {
361
+ this.pcmHeadOffset += remaining;
362
+ remaining = 0;
363
+ }
364
+ }
365
+ this.pcmQueuedBytes -= dropBytes - remaining;
366
+ }
367
+
368
+ /**
369
+ * Materialize and remove the next `bytesNeeded` contiguous bytes from the
370
+ * front of the queue. Returns a freshly-allocated Buffer (its own backing
371
+ * store, so it is safe to wrap in an Int16Array). Caller must ensure
372
+ * `pcmQueuedBytes >= bytesNeeded`.
373
+ */
374
+ private takePcm(bytesNeeded: number): Buffer {
375
+ const out = Buffer.allocUnsafe(bytesNeeded);
376
+ let written = 0;
377
+ while (written < bytesNeeded && this.pcmChunks.length) {
378
+ const head = this.pcmChunks[0];
379
+ const avail = head.length - this.pcmHeadOffset;
380
+ const take = Math.min(avail, bytesNeeded - written);
381
+ head.copy(out, written, this.pcmHeadOffset, this.pcmHeadOffset + take);
382
+ written += take;
383
+ if (take === avail) {
384
+ this.pcmChunks.shift();
385
+ this.pcmHeadOffset = 0;
386
+ } else {
387
+ this.pcmHeadOffset += take;
388
+ }
389
+ }
390
+ this.pcmQueuedBytes -= bytesNeeded;
391
+ return out;
392
+ }
393
+
332
394
  private enqueuePcm(
333
395
  session: Awaited<ReturnType<ReolinkBaichuanApi["createTalkSession"]>>,
334
396
  pcmChunk: Buffer,
@@ -339,20 +401,22 @@ export class ReolinkBaichuanIntercom {
339
401
 
340
402
  if (this.session !== session) return;
341
403
 
342
- this.pcmBuffer = this.pcmBuffer.length
343
- ? Buffer.concat([this.pcmBuffer, pcmChunk])
344
- : pcmChunk;
404
+ // Hot path: append the chunk reference, no per-chunk concat.
405
+ if (pcmChunk.length) {
406
+ this.pcmChunks.push(pcmChunk);
407
+ this.pcmQueuedBytes += pcmChunk.length;
408
+ }
345
409
 
346
410
  // Cap backlog to keep latency bounded (drop oldest samples).
347
- // IMPORTANT: do this on the shared buffer (not in a promise chain),
411
+ // IMPORTANT: do this on the shared queue (not in a promise chain),
348
412
  // otherwise old PCM chunks can pile up in queued closures and bypass
349
413
  // this clamp, causing multi-second latency and degraded audio.
350
414
  const maxBytes = this.maxBacklogBytes ?? bytesNeeded;
351
- if (this.pcmBuffer.length > maxBytes) {
415
+ if (this.pcmQueuedBytes > maxBytes) {
352
416
  // Align to 16-bit samples.
353
417
  const keep = maxBytes - (maxBytes % 2);
354
- const dropped = this.pcmBuffer.length - keep;
355
- this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
418
+ const dropped = this.pcmQueuedBytes - keep;
419
+ this.dropOldestPcm(dropped);
356
420
 
357
421
  const now = Date.now();
358
422
  if (now - this.lastBacklogClampLogAtMs > 2000) {
@@ -373,10 +437,9 @@ export class ReolinkBaichuanIntercom {
373
437
  const encode = await loadEncodeImaAdpcm();
374
438
  while (true) {
375
439
  if (this.session !== session) return;
376
- if (this.pcmBuffer.length < bytesNeeded) return;
440
+ if (this.pcmQueuedBytes < bytesNeeded) return;
377
441
 
378
- const chunk = this.pcmBuffer.subarray(0, bytesNeeded);
379
- this.pcmBuffer = this.pcmBuffer.subarray(bytesNeeded);
442
+ const chunk = this.takePcm(bytesNeeded);
380
443
 
381
444
  const pcmSamples = new Int16Array(
382
445
  chunk.buffer,
package/src/multiFocal.ts CHANGED
@@ -140,7 +140,7 @@ export class ReolinkNativeMultiFocalDevice
140
140
  await sdk.deviceManager.onDeviceDiscovered(device);
141
141
 
142
142
  logger.log(`Discovering lens ${lensType}`);
143
- logger.debug(`${JSON.stringify({ interfaces, deviceCapabilities })}`);
143
+ logger.debug({ interfaces, deviceCapabilities });
144
144
 
145
145
  const camera = await this.getDevice(nativeId);
146
146
 
package/src/nvr.ts CHANGED
@@ -289,7 +289,7 @@ export class ReolinkNativeNvrDevice
289
289
  const logger = this.getBaichuanLogger();
290
290
 
291
291
  try {
292
- logger.debug(`Baichuan event on nvr: ${JSON.stringify(ev)}`);
292
+ logger.debug("Baichuan event on nvr:", ev);
293
293
 
294
294
  const channel = ev?.channel;
295
295
  if (channel === undefined) {
package/src/utils.ts CHANGED
@@ -135,7 +135,7 @@ export const updateDeviceInfo = async (props: {
135
135
  throw e;
136
136
  } finally {
137
137
  logger.log(`Device info updated`);
138
- logger.debug(`${JSON.stringify({ newInfo: device.info, deviceData })}`);
138
+ logger.debug({ newInfo: device.info, deviceData });
139
139
  }
140
140
  };
141
141