@apocaliss92/scrypted-reolink-native 0.3.10 → 0.3.12

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.3.10",
3
+ "version": "0.3.12",
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",
package/src/camera.ts CHANGED
@@ -347,6 +347,13 @@ export class ReolinkCamera extends BaseBaichuanClass implements VideoCamera, Cam
347
347
  type: 'number',
348
348
  defaultValue: 1,
349
349
  },
350
+ intercomMaxBacklogMs: {
351
+ subgroup: 'Advanced',
352
+ title: 'Intercom Max Backlog (ms)',
353
+ description: 'Maximum PCM backlog before dropping old audio to cap latency. Higher improves stability on slow systems but increases latency. Typical: 80-250. Requires restarting talk session to take effect.',
354
+ type: 'number',
355
+ defaultValue: 120,
356
+ },
350
357
  // PTZ Presets
351
358
  presets: {
352
359
  subgroup: 'PTZ',
package/src/intercom.ts CHANGED
@@ -7,20 +7,25 @@ import { ReolinkCamera } from "./camera";
7
7
  // A small backlog avoids multi-second latency when the pipeline stalls.
8
8
  // Aim for ~1 block of latency (a block is ~64ms at 16kHz for Reolink talk).
9
9
  // This clamps the internal buffer to (approximately) one block.
10
- const DEFAULT_MAX_BACKLOG_MS = 40;
10
+ const DEFAULT_MAX_BACKLOG_MS = 120;
11
11
 
12
12
  export class ReolinkBaichuanIntercom {
13
+ private intercomApi: ReolinkBaichuanApi | undefined;
13
14
  private session: Awaited<ReturnType<ReolinkBaichuanApi["createTalkSession"]>> | undefined;
14
15
  private ffmpeg: ChildProcessWithoutNullStreams | undefined;
15
16
  private stopping: Promise<void> | undefined;
16
17
  private loggedCodecInfo = false;
17
18
 
18
- private readonly maxBacklogMs = DEFAULT_MAX_BACKLOG_MS;
19
+ private maxBacklogMs = DEFAULT_MAX_BACKLOG_MS;
19
20
  private maxBacklogBytes: number | undefined;
20
21
 
21
- private sendChain: Promise<void> = Promise.resolve();
22
22
  private pcmBuffer: Buffer = Buffer.alloc(0);
23
23
 
24
+ private pumping = false;
25
+ private pumpPromise: Promise<void> | undefined;
26
+
27
+ private lastBacklogClampLogAtMs = 0;
28
+
24
29
  constructor(private camera: ReolinkCamera) {
25
30
  }
26
31
 
@@ -39,13 +44,20 @@ export class ReolinkBaichuanIntercom {
39
44
  await this.stop();
40
45
  const channel = this.camera.storageSettings.values.rtspChannel;
41
46
 
47
+ // IMPORTANT: intercom must run on its own independent Baichuan session (separate socket)
48
+ // to avoid interference with any other sessions (streams/events/etc).
49
+ const intercomStreamKey = `intercom_${Date.now()}_${Math.random().toString(16).slice(2)}`;
50
+ const intercomApi = await this.camera.withBaichuanRetry(async () => {
51
+ return await this.camera.createStreamClient(intercomStreamKey);
52
+ });
53
+ this.intercomApi = intercomApi;
54
+
42
55
  // Best-effort: log codec requirements exposed by the camera.
43
56
  // This mirrors neolink's source of truth: TalkAbility (cmd_id=10).
44
57
  if (!this.loggedCodecInfo) {
45
58
  this.loggedCodecInfo = true;
46
59
  try {
47
- const api = await this.camera.ensureClient();
48
- const ability = await api.getTalkAbility(channel);
60
+ const ability = await intercomApi.getTalkAbility(channel);
49
61
  const audioConfigs = ability.audioConfigList?.map((c) => ({
50
62
  audioType: c.audioType,
51
63
  sampleRate: c.sampleRate,
@@ -66,7 +78,7 @@ export class ReolinkBaichuanIntercom {
66
78
  }
67
79
 
68
80
  const session = await this.camera.withBaichuanRetry(async () => {
69
- const api = await this.camera.ensureClient();
81
+ const api = intercomApi;
70
82
 
71
83
  // For UDP/battery cameras, wake up the camera if it's sleeping before creating talk session
72
84
  if (this.camera.options?.type === 'battery') {
@@ -90,11 +102,23 @@ export class ReolinkBaichuanIntercom {
90
102
 
91
103
  this.session = session;
92
104
  this.pcmBuffer = Buffer.alloc(0);
93
- this.sendChain = Promise.resolve();
105
+ this.pumping = false;
106
+ this.pumpPromise = undefined;
94
107
 
95
108
  const { audioConfig, blockSize, fullBlockSize } = session.info;
96
109
  const sampleRate = audioConfig.sampleRate;
97
110
 
111
+ // Configurable backlog to trade latency vs stability.
112
+ // If the pipeline (ffmpeg decode + encode + send) can't keep up,
113
+ // dropping old audio avoids accumulating multi-second latency.
114
+ const configuredBacklog = Number(this.camera.storageSettings.values.intercomMaxBacklogMs);
115
+ if (Number.isFinite(configuredBacklog)) {
116
+ this.maxBacklogMs = Math.max(20, Math.min(5000, configuredBacklog));
117
+ }
118
+ else {
119
+ this.maxBacklogMs = DEFAULT_MAX_BACKLOG_MS;
120
+ }
121
+
98
122
  // Mirror native-api.ts: receive PCM s16le from the forwarder and encode IMA ADPCM in JS.
99
123
  const samplesPerBlock = blockSize * 2 + 1;
100
124
  const bytesNeeded = samplesPerBlock * 2; // Int16 PCM
@@ -183,6 +207,9 @@ export class ReolinkBaichuanIntercom {
183
207
  const session = this.session;
184
208
  this.session = undefined;
185
209
 
210
+ const intercomApi = this.intercomApi;
211
+ this.intercomApi = undefined;
212
+
186
213
  this.pcmBuffer = Buffer.alloc(0);
187
214
 
188
215
  const sleepMs = async (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -207,12 +234,12 @@ export class ReolinkBaichuanIntercom {
207
234
  }
208
235
 
209
236
  try {
210
- await Promise.race([this.sendChain, sleepMs(250)]);
237
+ await Promise.race([this.pumpPromise ?? Promise.resolve(), sleepMs(250)]);
211
238
  }
212
239
  catch {
213
240
  // ignore
214
241
  }
215
- this.sendChain = Promise.resolve();
242
+ this.pumpPromise = undefined;
216
243
 
217
244
  if (session) {
218
245
  try {
@@ -222,6 +249,16 @@ export class ReolinkBaichuanIntercom {
222
249
  logger.warn("Intercom session stop error", e?.message || String(e));
223
250
  }
224
251
  }
252
+
253
+ // Close the dedicated intercom API session to keep it independent and short-lived.
254
+ if (intercomApi) {
255
+ try {
256
+ await Promise.race([intercomApi.close(), sleepMs(2000)]);
257
+ }
258
+ catch (e) {
259
+ logger.warn("Intercom client close error", e?.message || String(e));
260
+ }
261
+ }
225
262
  })().finally(() => {
226
263
  this.stopping = undefined;
227
264
  });
@@ -243,23 +280,43 @@ export class ReolinkBaichuanIntercom {
243
280
  ): void {
244
281
  const logger = this.camera.getBaichuanLogger();
245
282
 
246
- this.sendChain = this.sendChain
247
- .then(async () => {
248
- if (this.session !== session) return;
283
+ if (this.session !== session) return;
284
+
285
+ this.pcmBuffer = this.pcmBuffer.length
286
+ ? Buffer.concat([this.pcmBuffer, pcmChunk])
287
+ : pcmChunk;
288
+
289
+ // Cap backlog to keep latency bounded (drop oldest samples).
290
+ // IMPORTANT: do this on the shared buffer (not in a promise chain),
291
+ // otherwise old PCM chunks can pile up in queued closures and bypass
292
+ // this clamp, causing multi-second latency and degraded audio.
293
+ const maxBytes = this.maxBacklogBytes ?? bytesNeeded;
294
+ if (this.pcmBuffer.length > maxBytes) {
295
+ // Align to 16-bit samples.
296
+ const keep = maxBytes - (maxBytes % 2);
297
+ const dropped = this.pcmBuffer.length - keep;
298
+ this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
299
+
300
+ const now = Date.now();
301
+ if (now - this.lastBacklogClampLogAtMs > 2000) {
302
+ this.lastBacklogClampLogAtMs = now;
303
+ logger.warn("Intercom backlog clamped (dropping PCM)", {
304
+ droppedBytes: dropped,
305
+ keptBytes: keep,
306
+ maxBytes,
307
+ });
308
+ }
309
+ }
249
310
 
250
- this.pcmBuffer = this.pcmBuffer.length
251
- ? Buffer.concat([this.pcmBuffer, pcmChunk])
252
- : pcmChunk;
311
+ if (this.pumping) return;
253
312
 
254
- // Cap backlog to keep latency bounded (drop oldest samples).
255
- const maxBytes = this.maxBacklogBytes ?? bytesNeeded;
256
- if (this.pcmBuffer.length > maxBytes) {
257
- // Align to 16-bit samples.
258
- const keep = maxBytes - (maxBytes % 2);
259
- this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
260
- }
313
+ this.pumping = true;
314
+ this.pumpPromise = (async () => {
315
+ try {
316
+ while (true) {
317
+ if (this.session !== session) return;
318
+ if (this.pcmBuffer.length < bytesNeeded) return;
261
319
 
262
- while (this.pcmBuffer.length >= bytesNeeded) {
263
320
  const chunk = this.pcmBuffer.subarray(0, bytesNeeded);
264
321
  this.pcmBuffer = this.pcmBuffer.subarray(bytesNeeded);
265
322
 
@@ -272,10 +329,14 @@ export class ReolinkBaichuanIntercom {
272
329
  const adpcmChunk = this.encodeImaAdpcm(pcmSamples, blockSize);
273
330
  await session.sendAudio(adpcmChunk);
274
331
  }
275
- })
276
- .catch((e) => {
332
+ }
333
+ catch (e) {
277
334
  logger.warn("Intercom PCM->ADPCM pipeline error", e?.message || String(e));
278
- });
335
+ }
336
+ finally {
337
+ this.pumping = false;
338
+ }
339
+ })();
279
340
  }
280
341
 
281
342
  private buildFfmpegPcmArgs(