@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/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/camera.ts +16 -2
- package/src/intercom.ts +122 -26
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
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: '
|
|
345
|
-
title: '
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
257
|
+
await Promise.race([this.pumpPromise ?? Promise.resolve(), sleepMs(250)]);
|
|
211
258
|
}
|
|
212
259
|
catch {
|
|
213
260
|
// ignore
|
|
214
261
|
}
|
|
215
|
-
this.
|
|
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.
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
? Buffer.concat([this.pcmBuffer, pcmChunk])
|
|
252
|
-
: pcmChunk;
|
|
333
|
+
if (this.pumping) return;
|
|
253
334
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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(),
|