@apocaliss92/scrypted-reolink-native 0.3.10 → 0.3.13

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.13",
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
@@ -341,12 +341,26 @@ export class ReolinkCamera extends BaseBaichuanClass implements VideoCamera, Cam
341
341
  defaultValue: [],
342
342
  },
343
343
  intercomBlocksPerPayload: {
344
- subgroup: 'Advanced',
345
- title: 'Intercom Blocks Per Payload',
344
+ subgroup: 'Intercom',
345
+ title: 'Blocks Per Payload',
346
346
  description: 'Lower reduces latency (more packets). Typical: 1-4. Requires restarting talk session to take effect.',
347
347
  type: 'number',
348
348
  defaultValue: 1,
349
349
  },
350
+ intercomMaxBacklogMs: {
351
+ subgroup: 'Intercom',
352
+ title: '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
+ },
357
+ intercomGain: {
358
+ subgroup: 'Intercom',
359
+ title: 'Gain',
360
+ description: 'Output gain multiplier applied before encoding. 1.0 = normal, 2.0 ≈ +6dB, 0.5 ≈ -6dB. Requires restarting talk session to take effect.',
361
+ type: 'number',
362
+ defaultValue: 1.0,
363
+ },
350
364
  // PTZ Presets
351
365
  presets: {
352
366
  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
 
@@ -28,6 +33,13 @@ export class ReolinkBaichuanIntercom {
28
33
  return Math.max(1, Math.min(8, this.camera.storageSettings.values.intercomBlocksPerPayload ?? 1));
29
34
  }
30
35
 
36
+ private get outputGain(): number {
37
+ const configured = Number(this.camera.storageSettings.values.intercomGain);
38
+ // Keep safe bounds: too high can clip and distort.
39
+ if (Number.isFinite(configured)) return Math.max(0.1, Math.min(10, configured));
40
+ return 1.0;
41
+ }
42
+
31
43
  async start(media: MediaObject): Promise<void> {
32
44
  const logger = this.camera.getBaichuanLogger();
33
45
 
@@ -39,13 +51,21 @@ export class ReolinkBaichuanIntercom {
39
51
  await this.stop();
40
52
  const channel = this.camera.storageSettings.values.rtspChannel;
41
53
 
54
+ try {
55
+ // IMPORTANT: intercom must run on its own independent Baichuan session (separate socket)
56
+ // to avoid interference with any other sessions (streams/events/etc).
57
+ const intercomStreamKey = `intercom_${Date.now()}_${Math.random().toString(16).slice(2)}`;
58
+ const intercomApi = await this.camera.withBaichuanRetry(async () => {
59
+ return await this.camera.createStreamClient(intercomStreamKey);
60
+ });
61
+ this.intercomApi = intercomApi;
62
+
42
63
  // Best-effort: log codec requirements exposed by the camera.
43
64
  // This mirrors neolink's source of truth: TalkAbility (cmd_id=10).
44
65
  if (!this.loggedCodecInfo) {
45
66
  this.loggedCodecInfo = true;
46
67
  try {
47
- const api = await this.camera.ensureClient();
48
- const ability = await api.getTalkAbility(channel);
68
+ const ability = await intercomApi.getTalkAbility(channel);
49
69
  const audioConfigs = ability.audioConfigList?.map((c) => ({
50
70
  audioType: c.audioType,
51
71
  sampleRate: c.sampleRate,
@@ -66,7 +86,7 @@ export class ReolinkBaichuanIntercom {
66
86
  }
67
87
 
68
88
  const session = await this.camera.withBaichuanRetry(async () => {
69
- const api = await this.camera.ensureClient();
89
+ const api = intercomApi;
70
90
 
71
91
  // For UDP/battery cameras, wake up the camera if it's sleeping before creating talk session
72
92
  if (this.camera.options?.type === 'battery') {
@@ -85,16 +105,31 @@ export class ReolinkBaichuanIntercom {
85
105
 
86
106
  return await api.createTalkSession(channel, {
87
107
  blocksPerPayload: this.blocksPerPayload,
108
+ // IMPORTANT: for dedicated intercom sessions, teardown should be owned by the socket/session.
109
+ // This mirrors stream behavior (closeApiOnTeardown) but for talk: session.stop() will close.
110
+ closeSocketOnStop: true,
88
111
  });
89
112
  });
90
113
 
91
114
  this.session = session;
92
115
  this.pcmBuffer = Buffer.alloc(0);
93
- this.sendChain = Promise.resolve();
116
+ this.pumping = false;
117
+ this.pumpPromise = undefined;
94
118
 
95
119
  const { audioConfig, blockSize, fullBlockSize } = session.info;
96
120
  const sampleRate = audioConfig.sampleRate;
97
121
 
122
+ // Configurable backlog to trade latency vs stability.
123
+ // If the pipeline (ffmpeg decode + encode + send) can't keep up,
124
+ // dropping old audio avoids accumulating multi-second latency.
125
+ const configuredBacklog = Number(this.camera.storageSettings.values.intercomMaxBacklogMs);
126
+ if (Number.isFinite(configuredBacklog)) {
127
+ this.maxBacklogMs = Math.max(20, Math.min(5000, configuredBacklog));
128
+ }
129
+ else {
130
+ this.maxBacklogMs = DEFAULT_MAX_BACKLOG_MS;
131
+ }
132
+
98
133
  // Mirror native-api.ts: receive PCM s16le from the forwarder and encode IMA ADPCM in JS.
99
134
  const samplesPerBlock = blockSize * 2 + 1;
100
135
  const bytesNeeded = samplesPerBlock * 2; // Int16 PCM
@@ -131,9 +166,12 @@ export class ReolinkBaichuanIntercom {
131
166
 
132
167
  // IMPORTANT: incoming audio from Scrypted/WebRTC is typically Opus.
133
168
  // We must decode to PCM before IMA ADPCM encoding, otherwise it will be noise.
169
+ const gain = this.outputGain;
134
170
  const ffmpegArgs = this.buildFfmpegPcmArgs(ffmpegInput, {
135
171
  sampleRate,
136
172
  channels: 1,
173
+ gain,
174
+ logger,
137
175
  });
138
176
 
139
177
  logger.log("Intercom ffmpeg decode args", ffmpegArgs);
@@ -169,6 +207,12 @@ export class ReolinkBaichuanIntercom {
169
207
  });
170
208
 
171
209
  logger.log("Intercom started (ffmpeg decode -> PCM -> IMA ADPCM)");
210
+ }
211
+ catch (e) {
212
+ // Ensure the dedicated session gets torn down even if start fails half-way.
213
+ await this.stop();
214
+ throw e;
215
+ }
172
216
  }
173
217
 
174
218
  stop(): Promise<void> {
@@ -183,6 +227,9 @@ export class ReolinkBaichuanIntercom {
183
227
  const session = this.session;
184
228
  this.session = undefined;
185
229
 
230
+ const intercomApi = this.intercomApi;
231
+ this.intercomApi = undefined;
232
+
186
233
  this.pcmBuffer = Buffer.alloc(0);
187
234
 
188
235
  const sleepMs = async (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -207,12 +254,12 @@ export class ReolinkBaichuanIntercom {
207
254
  }
208
255
 
209
256
  try {
210
- await Promise.race([this.sendChain, sleepMs(250)]);
257
+ await Promise.race([this.pumpPromise ?? Promise.resolve(), sleepMs(250)]);
211
258
  }
212
259
  catch {
213
260
  // ignore
214
261
  }
215
- this.sendChain = Promise.resolve();
262
+ this.pumpPromise = undefined;
216
263
 
217
264
  if (session) {
218
265
  try {
@@ -222,6 +269,18 @@ export class ReolinkBaichuanIntercom {
222
269
  logger.warn("Intercom session stop error", e?.message || String(e));
223
270
  }
224
271
  }
272
+
273
+ // Socket teardown is handled by session.stop() (closeSocketOnStop).
274
+ // Fallback cleanup: if we never created a session but we did create a dedicated client,
275
+ // ensure it doesn't leak.
276
+ if (!session && intercomApi) {
277
+ try {
278
+ await Promise.race([intercomApi.close(), sleepMs(2000)]);
279
+ }
280
+ catch (e) {
281
+ logger.warn("Intercom client close error", e?.message || String(e));
282
+ }
283
+ }
225
284
  })().finally(() => {
226
285
  this.stopping = undefined;
227
286
  });
@@ -243,23 +302,43 @@ export class ReolinkBaichuanIntercom {
243
302
  ): void {
244
303
  const logger = this.camera.getBaichuanLogger();
245
304
 
246
- this.sendChain = this.sendChain
247
- .then(async () => {
248
- if (this.session !== session) return;
305
+ if (this.session !== session) return;
306
+
307
+ this.pcmBuffer = this.pcmBuffer.length
308
+ ? Buffer.concat([this.pcmBuffer, pcmChunk])
309
+ : pcmChunk;
310
+
311
+ // Cap backlog to keep latency bounded (drop oldest samples).
312
+ // IMPORTANT: do this on the shared buffer (not in a promise chain),
313
+ // otherwise old PCM chunks can pile up in queued closures and bypass
314
+ // this clamp, causing multi-second latency and degraded audio.
315
+ const maxBytes = this.maxBacklogBytes ?? bytesNeeded;
316
+ if (this.pcmBuffer.length > maxBytes) {
317
+ // Align to 16-bit samples.
318
+ const keep = maxBytes - (maxBytes % 2);
319
+ const dropped = this.pcmBuffer.length - keep;
320
+ this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
321
+
322
+ const now = Date.now();
323
+ if (now - this.lastBacklogClampLogAtMs > 2000) {
324
+ this.lastBacklogClampLogAtMs = now;
325
+ logger.warn("Intercom backlog clamped (dropping PCM)", {
326
+ droppedBytes: dropped,
327
+ keptBytes: keep,
328
+ maxBytes,
329
+ });
330
+ }
331
+ }
249
332
 
250
- this.pcmBuffer = this.pcmBuffer.length
251
- ? Buffer.concat([this.pcmBuffer, pcmChunk])
252
- : pcmChunk;
333
+ if (this.pumping) return;
253
334
 
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
- }
335
+ this.pumping = true;
336
+ this.pumpPromise = (async () => {
337
+ try {
338
+ while (true) {
339
+ if (this.session !== session) return;
340
+ if (this.pcmBuffer.length < bytesNeeded) return;
261
341
 
262
- while (this.pcmBuffer.length >= bytesNeeded) {
263
342
  const chunk = this.pcmBuffer.subarray(0, bytesNeeded);
264
343
  this.pcmBuffer = this.pcmBuffer.subarray(bytesNeeded);
265
344
 
@@ -272,10 +351,14 @@ export class ReolinkBaichuanIntercom {
272
351
  const adpcmChunk = this.encodeImaAdpcm(pcmSamples, blockSize);
273
352
  await session.sendAudio(adpcmChunk);
274
353
  }
275
- })
276
- .catch((e) => {
354
+ }
355
+ catch (e) {
277
356
  logger.warn("Intercom PCM->ADPCM pipeline error", e?.message || String(e));
278
- });
357
+ }
358
+ finally {
359
+ this.pumping = false;
360
+ }
361
+ })();
279
362
  }
280
363
 
281
364
  private buildFfmpegPcmArgs(
@@ -283,6 +366,8 @@ export class ReolinkBaichuanIntercom {
283
366
  options: {
284
367
  sampleRate: number;
285
368
  channels: number;
369
+ gain?: number;
370
+ logger?: any;
286
371
  },
287
372
  ): string[] {
288
373
  const inputArgs = ffmpegInput.inputArguments ?? [];
@@ -314,6 +399,16 @@ export class ReolinkBaichuanIntercom {
314
399
  throw new Error("FFmpegInput missing url/input");
315
400
  }
316
401
 
402
+ const gain = options.gain ?? 1.0;
403
+ const hasExistingAudioFilter = sanitizedArgs.includes("-af") || sanitizedArgs.includes("-filter:a") || sanitizedArgs.includes("-filter_complex");
404
+ const gainArgs = (gain !== 1.0)
405
+ ? (
406
+ hasExistingAudioFilter
407
+ ? (options.logger?.warn?.("Intercom gain skipped: FFmpegInput already contains audio filters") ?? undefined, [])
408
+ : ["-filter:a", `volume=${gain}`]
409
+ )
410
+ : [];
411
+
317
412
  return [
318
413
  ...sanitizedArgs,
319
414
  "-i", url,
@@ -326,6 +421,7 @@ export class ReolinkBaichuanIntercom {
326
421
  "-flush_packets", "1",
327
422
 
328
423
  "-vn", "-sn", "-dn",
424
+ ...gainArgs,
329
425
  "-acodec", "pcm_s16le",
330
426
  "-ar", options.sampleRate.toString(),
331
427
  "-ac", options.channels.toString(),