@apocaliss92/nodelink-js 0.4.35 → 0.5.0
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/{chunk-N6TCSER3.js → chunk-6ILAHQF5.js} +22 -3
- package/dist/{chunk-N6TCSER3.js.map → chunk-6ILAHQF5.js.map} +1 -1
- package/dist/cli/rtsp-server.cjs +21 -2
- package/dist/cli/rtsp-server.cjs.map +1 -1
- package/dist/cli/rtsp-server.js +1 -1
- package/dist/index.cjs +978 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +237 -2
- package/dist/index.d.ts +242 -1
- package/dist/index.js +949 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
setEmailPushCameraResolver,
|
|
68
68
|
setGlobalLogger,
|
|
69
69
|
xmlIndicatesFloodlight
|
|
70
|
-
} from "./chunk-
|
|
70
|
+
} from "./chunk-6ILAHQF5.js";
|
|
71
71
|
import {
|
|
72
72
|
ReolinkCgiApi,
|
|
73
73
|
ReolinkHttpClient,
|
|
@@ -8285,6 +8285,947 @@ function base64DecodeToBytes(b64) {
|
|
|
8285
8285
|
return out.subarray(0, outIdx);
|
|
8286
8286
|
}
|
|
8287
8287
|
|
|
8288
|
+
// src/reolink/baichuan/utils/imaAdpcm.ts
|
|
8289
|
+
var IMA_INDEX_TABLE = [
|
|
8290
|
+
-1,
|
|
8291
|
+
-1,
|
|
8292
|
+
-1,
|
|
8293
|
+
-1,
|
|
8294
|
+
2,
|
|
8295
|
+
4,
|
|
8296
|
+
6,
|
|
8297
|
+
8,
|
|
8298
|
+
-1,
|
|
8299
|
+
-1,
|
|
8300
|
+
-1,
|
|
8301
|
+
-1,
|
|
8302
|
+
2,
|
|
8303
|
+
4,
|
|
8304
|
+
6,
|
|
8305
|
+
8
|
|
8306
|
+
];
|
|
8307
|
+
var IMA_STEP_TABLE = [
|
|
8308
|
+
7,
|
|
8309
|
+
8,
|
|
8310
|
+
9,
|
|
8311
|
+
10,
|
|
8312
|
+
11,
|
|
8313
|
+
12,
|
|
8314
|
+
13,
|
|
8315
|
+
14,
|
|
8316
|
+
16,
|
|
8317
|
+
17,
|
|
8318
|
+
19,
|
|
8319
|
+
21,
|
|
8320
|
+
23,
|
|
8321
|
+
25,
|
|
8322
|
+
28,
|
|
8323
|
+
31,
|
|
8324
|
+
34,
|
|
8325
|
+
37,
|
|
8326
|
+
41,
|
|
8327
|
+
45,
|
|
8328
|
+
50,
|
|
8329
|
+
55,
|
|
8330
|
+
60,
|
|
8331
|
+
66,
|
|
8332
|
+
73,
|
|
8333
|
+
80,
|
|
8334
|
+
88,
|
|
8335
|
+
97,
|
|
8336
|
+
107,
|
|
8337
|
+
118,
|
|
8338
|
+
130,
|
|
8339
|
+
143,
|
|
8340
|
+
157,
|
|
8341
|
+
173,
|
|
8342
|
+
190,
|
|
8343
|
+
209,
|
|
8344
|
+
230,
|
|
8345
|
+
253,
|
|
8346
|
+
279,
|
|
8347
|
+
307,
|
|
8348
|
+
337,
|
|
8349
|
+
371,
|
|
8350
|
+
408,
|
|
8351
|
+
449,
|
|
8352
|
+
494,
|
|
8353
|
+
544,
|
|
8354
|
+
598,
|
|
8355
|
+
658,
|
|
8356
|
+
724,
|
|
8357
|
+
796,
|
|
8358
|
+
876,
|
|
8359
|
+
963,
|
|
8360
|
+
1060,
|
|
8361
|
+
1166,
|
|
8362
|
+
1282,
|
|
8363
|
+
1411,
|
|
8364
|
+
1552,
|
|
8365
|
+
1707,
|
|
8366
|
+
1878,
|
|
8367
|
+
2066,
|
|
8368
|
+
2272,
|
|
8369
|
+
2499,
|
|
8370
|
+
2749,
|
|
8371
|
+
3024,
|
|
8372
|
+
3327,
|
|
8373
|
+
3660,
|
|
8374
|
+
4026,
|
|
8375
|
+
4428,
|
|
8376
|
+
4871,
|
|
8377
|
+
5358,
|
|
8378
|
+
5894,
|
|
8379
|
+
6484,
|
|
8380
|
+
7132,
|
|
8381
|
+
7845,
|
|
8382
|
+
8630,
|
|
8383
|
+
9493,
|
|
8384
|
+
10442,
|
|
8385
|
+
11487,
|
|
8386
|
+
12635,
|
|
8387
|
+
13899,
|
|
8388
|
+
15289,
|
|
8389
|
+
16818,
|
|
8390
|
+
18500,
|
|
8391
|
+
20350,
|
|
8392
|
+
22385,
|
|
8393
|
+
24623,
|
|
8394
|
+
27086,
|
|
8395
|
+
29794,
|
|
8396
|
+
32767
|
|
8397
|
+
];
|
|
8398
|
+
function clamp16(x) {
|
|
8399
|
+
if (x > 32767) return 32767;
|
|
8400
|
+
if (x < -32768) return -32768;
|
|
8401
|
+
return x | 0;
|
|
8402
|
+
}
|
|
8403
|
+
function encodeImaAdpcm(pcm, blockSizeBytes) {
|
|
8404
|
+
if (pcm.length === 0) return Buffer.alloc(0);
|
|
8405
|
+
if (!Number.isInteger(blockSizeBytes) || blockSizeBytes <= 0) {
|
|
8406
|
+
throw new RangeError(
|
|
8407
|
+
`encodeImaAdpcm: blockSizeBytes must be a positive integer, got ${blockSizeBytes}`
|
|
8408
|
+
);
|
|
8409
|
+
}
|
|
8410
|
+
const samplesPerBlock = blockSizeBytes * 2 + 1;
|
|
8411
|
+
const totalBlocks = Math.ceil(pcm.length / samplesPerBlock);
|
|
8412
|
+
const out = Buffer.alloc(totalBlocks * (4 + blockSizeBytes));
|
|
8413
|
+
let sampleIndex = 0;
|
|
8414
|
+
let outOffset = 0;
|
|
8415
|
+
for (let b = 0; b < totalBlocks; b++) {
|
|
8416
|
+
const blockStart = outOffset;
|
|
8417
|
+
const first = pcm[sampleIndex] ?? 0;
|
|
8418
|
+
let predictor = first;
|
|
8419
|
+
let index = 0;
|
|
8420
|
+
out.writeInt16LE(predictor, blockStart);
|
|
8421
|
+
out.writeUInt8(index, blockStart + 2);
|
|
8422
|
+
out.writeUInt8(0, blockStart + 3);
|
|
8423
|
+
sampleIndex++;
|
|
8424
|
+
const codes = new Uint8Array(blockSizeBytes * 2);
|
|
8425
|
+
for (let i = 0; i < codes.length; i++) {
|
|
8426
|
+
const sample = pcm[sampleIndex] ?? predictor;
|
|
8427
|
+
sampleIndex++;
|
|
8428
|
+
let diff = sample - predictor;
|
|
8429
|
+
let sign = 0;
|
|
8430
|
+
if (diff < 0) {
|
|
8431
|
+
sign = 8;
|
|
8432
|
+
diff = -diff;
|
|
8433
|
+
}
|
|
8434
|
+
let step = IMA_STEP_TABLE[index] ?? 7;
|
|
8435
|
+
let delta = 0;
|
|
8436
|
+
let vpdiff = step >> 3;
|
|
8437
|
+
if (diff >= step) {
|
|
8438
|
+
delta |= 4;
|
|
8439
|
+
diff -= step;
|
|
8440
|
+
vpdiff += step;
|
|
8441
|
+
}
|
|
8442
|
+
step >>= 1;
|
|
8443
|
+
if (diff >= step) {
|
|
8444
|
+
delta |= 2;
|
|
8445
|
+
diff -= step;
|
|
8446
|
+
vpdiff += step;
|
|
8447
|
+
}
|
|
8448
|
+
step >>= 1;
|
|
8449
|
+
if (diff >= step) {
|
|
8450
|
+
delta |= 1;
|
|
8451
|
+
vpdiff += step;
|
|
8452
|
+
}
|
|
8453
|
+
predictor = clamp16(sign ? predictor - vpdiff : predictor + vpdiff);
|
|
8454
|
+
index += IMA_INDEX_TABLE[delta] ?? 0;
|
|
8455
|
+
if (index < 0) index = 0;
|
|
8456
|
+
if (index > 88) index = 88;
|
|
8457
|
+
codes[i] = (delta | sign) & 15;
|
|
8458
|
+
}
|
|
8459
|
+
for (let i = 0; i < blockSizeBytes; i++) {
|
|
8460
|
+
const lo = codes[i * 2] ?? 0;
|
|
8461
|
+
const hi = codes[i * 2 + 1] ?? 0;
|
|
8462
|
+
out[blockStart + 4 + i] = lo & 15 | (hi & 15) << 4;
|
|
8463
|
+
}
|
|
8464
|
+
outOffset += 4 + blockSizeBytes;
|
|
8465
|
+
}
|
|
8466
|
+
return out;
|
|
8467
|
+
}
|
|
8468
|
+
|
|
8469
|
+
// src/reolink/baichuan/utils/audioMulaw.ts
|
|
8470
|
+
import alawmulaw from "alawmulaw";
|
|
8471
|
+
var { mulaw, alaw } = alawmulaw;
|
|
8472
|
+
function mulawToPcm16(bytes) {
|
|
8473
|
+
if (bytes.length === 0) return new Int16Array(0);
|
|
8474
|
+
return mulaw.decode(bytes);
|
|
8475
|
+
}
|
|
8476
|
+
function alawToPcm16(bytes) {
|
|
8477
|
+
if (bytes.length === 0) return new Int16Array(0);
|
|
8478
|
+
return alaw.decode(bytes);
|
|
8479
|
+
}
|
|
8480
|
+
|
|
8481
|
+
// src/reolink/baichuan/utils/audioResample.ts
|
|
8482
|
+
function clamp162(x) {
|
|
8483
|
+
if (x > 32767) return 32767;
|
|
8484
|
+
if (x < -32768) return -32768;
|
|
8485
|
+
return x | 0;
|
|
8486
|
+
}
|
|
8487
|
+
function upsamplePcm16(src, factor) {
|
|
8488
|
+
if (!Number.isInteger(factor) || factor < 1) {
|
|
8489
|
+
throw new RangeError(
|
|
8490
|
+
`upsamplePcm16: factor must be a positive integer, got ${factor}`
|
|
8491
|
+
);
|
|
8492
|
+
}
|
|
8493
|
+
if (src.length === 0) return new Int16Array(0);
|
|
8494
|
+
if (factor === 1) return Int16Array.from(src);
|
|
8495
|
+
const out = new Int16Array(src.length * factor);
|
|
8496
|
+
const last = src.length - 1;
|
|
8497
|
+
for (let i = 0; i < last; i++) {
|
|
8498
|
+
const a = src[i];
|
|
8499
|
+
const b = src[i + 1];
|
|
8500
|
+
const base = i * factor;
|
|
8501
|
+
for (let k = 0; k < factor; k++) {
|
|
8502
|
+
const v = a + Math.round((b - a) * k / factor);
|
|
8503
|
+
out[base + k] = clamp162(v);
|
|
8504
|
+
}
|
|
8505
|
+
}
|
|
8506
|
+
const tail = src[last] | 0;
|
|
8507
|
+
for (let k = 0; k < factor; k++) {
|
|
8508
|
+
out[last * factor + k] = tail;
|
|
8509
|
+
}
|
|
8510
|
+
return out;
|
|
8511
|
+
}
|
|
8512
|
+
|
|
8513
|
+
// src/baichuan/stream/RtspBackchannel.ts
|
|
8514
|
+
var RTP_PT_PCMA = 8;
|
|
8515
|
+
var RTP_FIXED_HEADER_BYTES = 12;
|
|
8516
|
+
var G711_SOURCE_RATE_HZ = 8e3;
|
|
8517
|
+
var TALK_TARGET_RATE_HZ = 16e3;
|
|
8518
|
+
function parseRtpPacket(packet) {
|
|
8519
|
+
if (packet.length < RTP_FIXED_HEADER_BYTES) return null;
|
|
8520
|
+
const b0 = packet.readUInt8(0);
|
|
8521
|
+
const version = b0 >> 6;
|
|
8522
|
+
if (version !== 2) return null;
|
|
8523
|
+
const padding = (b0 & 32) !== 0;
|
|
8524
|
+
const extension = (b0 & 16) !== 0;
|
|
8525
|
+
const csrcCount = b0 & 15;
|
|
8526
|
+
const b1 = packet.readUInt8(1);
|
|
8527
|
+
const payloadType = b1 & 127;
|
|
8528
|
+
let offset = RTP_FIXED_HEADER_BYTES + csrcCount * 4;
|
|
8529
|
+
if (offset > packet.length) return null;
|
|
8530
|
+
if (extension) {
|
|
8531
|
+
if (offset + 4 > packet.length) return null;
|
|
8532
|
+
const extensionLengthWords = packet.readUInt16BE(offset + 2);
|
|
8533
|
+
offset += 4 + extensionLengthWords * 4;
|
|
8534
|
+
if (offset > packet.length) return null;
|
|
8535
|
+
}
|
|
8536
|
+
let end = packet.length;
|
|
8537
|
+
if (padding) {
|
|
8538
|
+
if (end - offset < 1) return null;
|
|
8539
|
+
const padLength = packet.readUInt8(end - 1);
|
|
8540
|
+
if (padLength < 1 || padLength > end - offset) return null;
|
|
8541
|
+
end -= padLength;
|
|
8542
|
+
}
|
|
8543
|
+
if (end <= offset) return null;
|
|
8544
|
+
return {
|
|
8545
|
+
payloadType,
|
|
8546
|
+
payload: packet.subarray(offset, end)
|
|
8547
|
+
};
|
|
8548
|
+
}
|
|
8549
|
+
var RtspBackchannel = class _RtspBackchannel {
|
|
8550
|
+
constructor(opts) {
|
|
8551
|
+
this.opts = opts;
|
|
8552
|
+
}
|
|
8553
|
+
session = void 0;
|
|
8554
|
+
pcmTail = new Int16Array(0);
|
|
8555
|
+
// residual samples below one full ADPCM block
|
|
8556
|
+
pcmBacklogBytes = 0;
|
|
8557
|
+
pumping = false;
|
|
8558
|
+
active = false;
|
|
8559
|
+
starting = void 0;
|
|
8560
|
+
stopping = void 0;
|
|
8561
|
+
lastBacklogClampLogMs = 0;
|
|
8562
|
+
rtpPacketsReceived = 0;
|
|
8563
|
+
rtpPacketsDropped = 0;
|
|
8564
|
+
adpcmBlocksSent = 0;
|
|
8565
|
+
/** Lazily-set on the first decoded RTP packet so we log the negotiated codec exactly once. */
|
|
8566
|
+
observedCodec = void 0;
|
|
8567
|
+
/** Sampled every `STATS_LOG_INTERVAL_MS` while audio flows. */
|
|
8568
|
+
lastStatsLogMs = 0;
|
|
8569
|
+
lastStatsAdpcmBlocks = 0;
|
|
8570
|
+
lastStatsRtpPackets = 0;
|
|
8571
|
+
startedAtMs = 0;
|
|
8572
|
+
static STATS_LOG_INTERVAL_MS = 5e3;
|
|
8573
|
+
get isActive() {
|
|
8574
|
+
return this.active;
|
|
8575
|
+
}
|
|
8576
|
+
get stats() {
|
|
8577
|
+
return {
|
|
8578
|
+
rtpPacketsReceived: this.rtpPacketsReceived,
|
|
8579
|
+
rtpPacketsDropped: this.rtpPacketsDropped,
|
|
8580
|
+
adpcmBlocksSent: this.adpcmBlocksSent
|
|
8581
|
+
};
|
|
8582
|
+
}
|
|
8583
|
+
/** Open the underlying TalkSession. Safe to call concurrently; resolves to the same session. */
|
|
8584
|
+
async start() {
|
|
8585
|
+
if (this.session) return this.session;
|
|
8586
|
+
if (!this.starting) {
|
|
8587
|
+
const openStart = Date.now();
|
|
8588
|
+
this.opts.logger?.log?.(
|
|
8589
|
+
`[RtspBackchannel] opening TalkSession on camera\u2026`
|
|
8590
|
+
);
|
|
8591
|
+
this.starting = (async () => {
|
|
8592
|
+
const s = await this.opts.openTalkSession();
|
|
8593
|
+
this.session = s;
|
|
8594
|
+
this.active = true;
|
|
8595
|
+
this.startedAtMs = Date.now();
|
|
8596
|
+
this.lastStatsLogMs = this.startedAtMs;
|
|
8597
|
+
this.opts.logger?.log?.(
|
|
8598
|
+
`[RtspBackchannel] TalkSession opened channel=${s.info.channel} block=${s.info.blockSize} fullBlock=${s.info.fullBlockSize} audioType=${s.info.audioConfig.audioType} sampleRate=${s.info.audioConfig.sampleRate} samplePrecision=${s.info.audioConfig.samplePrecision} soundTrack=${s.info.audioConfig.soundTrack} openMs=${Date.now() - openStart}`
|
|
8599
|
+
);
|
|
8600
|
+
return s;
|
|
8601
|
+
})().catch((e) => {
|
|
8602
|
+
this.starting = void 0;
|
|
8603
|
+
this.opts.logger?.log?.(
|
|
8604
|
+
`[RtspBackchannel] TalkSession open FAILED openMs=${Date.now() - openStart} error="${e.message}"`
|
|
8605
|
+
);
|
|
8606
|
+
throw e;
|
|
8607
|
+
});
|
|
8608
|
+
}
|
|
8609
|
+
return this.starting;
|
|
8610
|
+
}
|
|
8611
|
+
/**
|
|
8612
|
+
* Feed one inbound RTP packet (as carried in TCP-interleaved framing or UDP
|
|
8613
|
+
* datagram). Discards malformed packets and packets received before `start()`.
|
|
8614
|
+
* The actual audio dispatch to the TalkSession is awaited internally; callers
|
|
8615
|
+
* may fire-and-forget.
|
|
8616
|
+
*/
|
|
8617
|
+
feedRtp(packet) {
|
|
8618
|
+
this.rtpPacketsReceived++;
|
|
8619
|
+
if (!this.active || !this.session) {
|
|
8620
|
+
this.rtpPacketsDropped++;
|
|
8621
|
+
if (this.rtpPacketsDropped === 1) {
|
|
8622
|
+
this.opts.logger?.log?.(
|
|
8623
|
+
`[RtspBackchannel] dropping RTP packets \u2014 session not active yet (received ${packet.length}B before start())`
|
|
8624
|
+
);
|
|
8625
|
+
}
|
|
8626
|
+
return;
|
|
8627
|
+
}
|
|
8628
|
+
const parsed = parseRtpPacket(packet);
|
|
8629
|
+
if (!parsed) {
|
|
8630
|
+
this.rtpPacketsDropped++;
|
|
8631
|
+
this.opts.logger?.log?.(
|
|
8632
|
+
`[RtspBackchannel] malformed RTP packet \u2014 dropping (len=${packet.length} firstByte=0x${packet.length ? (packet[0] ?? 0).toString(16) : "??"})`
|
|
8633
|
+
);
|
|
8634
|
+
return;
|
|
8635
|
+
}
|
|
8636
|
+
const codec = this.opts.forceCodec ?? (parsed.payloadType === RTP_PT_PCMA ? "pcma" : "pcmu");
|
|
8637
|
+
if (this.observedCodec === void 0) {
|
|
8638
|
+
this.observedCodec = codec;
|
|
8639
|
+
const firstPacketMs = Date.now() - this.startedAtMs;
|
|
8640
|
+
this.opts.logger?.log?.(
|
|
8641
|
+
`[RtspBackchannel] first RTP packet \u2014 codec=${codec} payloadType=${parsed.payloadType} payload=${parsed.payload.length}B firstByteMs=${firstPacketMs}` + (this.opts.forceCodec ? " (forced by config)" : "")
|
|
8642
|
+
);
|
|
8643
|
+
} else if (codec !== this.observedCodec) {
|
|
8644
|
+
this.opts.logger?.log?.(
|
|
8645
|
+
`[RtspBackchannel] codec switched mid-stream ${this.observedCodec} \u2192 ${codec} (payloadType=${parsed.payloadType})`
|
|
8646
|
+
);
|
|
8647
|
+
this.observedCodec = codec;
|
|
8648
|
+
}
|
|
8649
|
+
const decoded = codec === "pcma" ? alawToPcm16(parsed.payload) : mulawToPcm16(parsed.payload);
|
|
8650
|
+
if (decoded.length === 0) return;
|
|
8651
|
+
const upsampled = upsamplePcm16(decoded, TALK_TARGET_RATE_HZ / G711_SOURCE_RATE_HZ);
|
|
8652
|
+
this.enqueuePcm(upsampled);
|
|
8653
|
+
this.maybeLogStats();
|
|
8654
|
+
}
|
|
8655
|
+
/**
|
|
8656
|
+
* Throttled progress log (~every 5s while audio is flowing) so operators
|
|
8657
|
+
* can confirm the pipeline is making progress without per-packet noise.
|
|
8658
|
+
*/
|
|
8659
|
+
maybeLogStats() {
|
|
8660
|
+
const now = Date.now();
|
|
8661
|
+
if (now - this.lastStatsLogMs < _RtspBackchannel.STATS_LOG_INTERVAL_MS) return;
|
|
8662
|
+
const elapsedSec = (now - this.lastStatsLogMs) / 1e3;
|
|
8663
|
+
const rtpDelta = this.rtpPacketsReceived - this.lastStatsRtpPackets;
|
|
8664
|
+
const adpcmDelta = this.adpcmBlocksSent - this.lastStatsAdpcmBlocks;
|
|
8665
|
+
this.lastStatsLogMs = now;
|
|
8666
|
+
this.lastStatsRtpPackets = this.rtpPacketsReceived;
|
|
8667
|
+
this.lastStatsAdpcmBlocks = this.adpcmBlocksSent;
|
|
8668
|
+
this.opts.logger?.log?.(
|
|
8669
|
+
`[RtspBackchannel] stats codec=${this.observedCodec ?? "?"} rtpRx=${this.rtpPacketsReceived} (+${rtpDelta} in ${elapsedSec.toFixed(1)}s) dropped=${this.rtpPacketsDropped} adpcmBlocks=${this.adpcmBlocksSent} (+${adpcmDelta}) pcmBacklog=${this.pcmBacklogBytes}B`
|
|
8670
|
+
);
|
|
8671
|
+
}
|
|
8672
|
+
/** Flush remaining audio and stop the talk session. Idempotent. */
|
|
8673
|
+
async stop() {
|
|
8674
|
+
if (this.stopping) return this.stopping;
|
|
8675
|
+
this.active = false;
|
|
8676
|
+
this.stopping = (async () => {
|
|
8677
|
+
const s = this.session;
|
|
8678
|
+
this.session = void 0;
|
|
8679
|
+
this.pcmTail = new Int16Array(0);
|
|
8680
|
+
this.pcmBacklogBytes = 0;
|
|
8681
|
+
const durationMs = this.startedAtMs ? Date.now() - this.startedAtMs : 0;
|
|
8682
|
+
this.opts.logger?.log?.(
|
|
8683
|
+
`[RtspBackchannel] closing TalkSession durationMs=${durationMs} rtpRx=${this.rtpPacketsReceived} rtpDropped=${this.rtpPacketsDropped} adpcmBlocks=${this.adpcmBlocksSent} pcmBacklogResidual=${this.pcmBacklogBytes}B codec=${this.observedCodec ?? "(none)"}`
|
|
8684
|
+
);
|
|
8685
|
+
if (s) {
|
|
8686
|
+
try {
|
|
8687
|
+
await s.stop();
|
|
8688
|
+
} catch (e) {
|
|
8689
|
+
this.opts.logger?.log?.(
|
|
8690
|
+
`[RtspBackchannel] TalkSession stop error: ${e.message}`
|
|
8691
|
+
);
|
|
8692
|
+
}
|
|
8693
|
+
}
|
|
8694
|
+
})();
|
|
8695
|
+
return this.stopping;
|
|
8696
|
+
}
|
|
8697
|
+
enqueuePcm(chunk) {
|
|
8698
|
+
if (chunk.length === 0) return;
|
|
8699
|
+
const tailLen = this.pcmTail.length;
|
|
8700
|
+
const merged = new Int16Array(tailLen + chunk.length);
|
|
8701
|
+
merged.set(this.pcmTail, 0);
|
|
8702
|
+
merged.set(chunk, tailLen);
|
|
8703
|
+
this.pcmBacklogBytes = merged.length * 2;
|
|
8704
|
+
const maxBytes = Math.max(2, this.opts.maxPcmBacklogBytes ?? 96e3);
|
|
8705
|
+
if (this.pcmBacklogBytes > maxBytes) {
|
|
8706
|
+
const keepSamples = Math.floor(maxBytes / 2);
|
|
8707
|
+
const dropSamples = merged.length - keepSamples;
|
|
8708
|
+
this.pcmTail = merged.subarray(merged.length - keepSamples);
|
|
8709
|
+
this.pcmBacklogBytes = this.pcmTail.length * 2;
|
|
8710
|
+
const now = Date.now();
|
|
8711
|
+
if (now - this.lastBacklogClampLogMs > 2e3) {
|
|
8712
|
+
this.lastBacklogClampLogMs = now;
|
|
8713
|
+
this.opts.logger?.log?.(
|
|
8714
|
+
`[RtspBackchannel] PCM backlog clamped \u2014 dropped ${dropSamples} samples, kept ${keepSamples}`
|
|
8715
|
+
);
|
|
8716
|
+
}
|
|
8717
|
+
} else {
|
|
8718
|
+
this.pcmTail = merged;
|
|
8719
|
+
}
|
|
8720
|
+
void this.pumpAdpcm();
|
|
8721
|
+
}
|
|
8722
|
+
async pumpAdpcm() {
|
|
8723
|
+
if (this.pumping) return;
|
|
8724
|
+
const session = this.session;
|
|
8725
|
+
if (!session) return;
|
|
8726
|
+
this.pumping = true;
|
|
8727
|
+
try {
|
|
8728
|
+
const samplesPerBlock = session.info.blockSize * 2 + 1;
|
|
8729
|
+
const blockSizeBytes = session.info.blockSize;
|
|
8730
|
+
while (this.active && this.pcmTail.length >= samplesPerBlock) {
|
|
8731
|
+
const consumeSamples = Math.floor(this.pcmTail.length / samplesPerBlock) * samplesPerBlock;
|
|
8732
|
+
const head = this.pcmTail.subarray(0, consumeSamples);
|
|
8733
|
+
const adpcm = encodeImaAdpcm(head, blockSizeBytes);
|
|
8734
|
+
const blocksInBatch = consumeSamples / samplesPerBlock;
|
|
8735
|
+
this.pcmTail = this.pcmTail.slice(consumeSamples);
|
|
8736
|
+
this.pcmBacklogBytes = this.pcmTail.length * 2;
|
|
8737
|
+
try {
|
|
8738
|
+
await session.sendAudio(adpcm);
|
|
8739
|
+
this.adpcmBlocksSent += blocksInBatch;
|
|
8740
|
+
} catch (e) {
|
|
8741
|
+
this.opts.logger?.log?.(
|
|
8742
|
+
`[RtspBackchannel] sendAudio FAILED \u2014 disabling pipeline blocksInBatch=${blocksInBatch} adpcmBytes=${adpcm.length} pcmBacklogBefore=${this.pcmBacklogBytes + adpcm.length}B error="${e.message}"`
|
|
8743
|
+
);
|
|
8744
|
+
this.pcmTail = new Int16Array(0);
|
|
8745
|
+
this.pcmBacklogBytes = 0;
|
|
8746
|
+
this.active = false;
|
|
8747
|
+
break;
|
|
8748
|
+
}
|
|
8749
|
+
}
|
|
8750
|
+
} finally {
|
|
8751
|
+
this.pumping = false;
|
|
8752
|
+
}
|
|
8753
|
+
}
|
|
8754
|
+
};
|
|
8755
|
+
|
|
8756
|
+
// src/baichuan/stream/BaichuanRtspBackchannelServer.ts
|
|
8757
|
+
import { EventEmitter as EventEmitter10 } from "events";
|
|
8758
|
+
import * as net3 from "net";
|
|
8759
|
+
import * as crypto2 from "crypto";
|
|
8760
|
+
var md5Hex = (s) => crypto2.createHash("md5").update(s).digest("hex");
|
|
8761
|
+
var BaichuanRtspBackchannelServer = class _BaichuanRtspBackchannelServer extends EventEmitter10 {
|
|
8762
|
+
api;
|
|
8763
|
+
channel;
|
|
8764
|
+
listenHost;
|
|
8765
|
+
listenPort;
|
|
8766
|
+
path;
|
|
8767
|
+
logger;
|
|
8768
|
+
authCredentials;
|
|
8769
|
+
requireAuth;
|
|
8770
|
+
authRealm;
|
|
8771
|
+
deviceId;
|
|
8772
|
+
server = void 0;
|
|
8773
|
+
/** Active backchannel sessions keyed by their per-client unique id. */
|
|
8774
|
+
sessionByClient = /* @__PURE__ */ new Map();
|
|
8775
|
+
nonces = /* @__PURE__ */ new Map();
|
|
8776
|
+
static NONCE_TTL_MS = 5 * 60 * 1e3;
|
|
8777
|
+
constructor(options) {
|
|
8778
|
+
super();
|
|
8779
|
+
this.api = options.api;
|
|
8780
|
+
this.channel = options.channel;
|
|
8781
|
+
this.listenHost = options.listenHost ?? "127.0.0.1";
|
|
8782
|
+
this.listenPort = options.listenPort ?? 8555;
|
|
8783
|
+
this.path = options.path ?? "/talk";
|
|
8784
|
+
this.logger = options.logger ?? console;
|
|
8785
|
+
this.authCredentials = (options.credentials ?? []).map((c) => ({
|
|
8786
|
+
username: c.username,
|
|
8787
|
+
...c.password !== void 0 ? { password: c.password } : {},
|
|
8788
|
+
...c.ha1 !== void 0 ? { ha1: c.ha1 } : {}
|
|
8789
|
+
}));
|
|
8790
|
+
this.requireAuth = options.requireAuth ?? this.authCredentials.length > 0;
|
|
8791
|
+
this.authRealm = options.authRealm ?? "BaichuanRtspBackchannelServer";
|
|
8792
|
+
this.deviceId = options.deviceId;
|
|
8793
|
+
}
|
|
8794
|
+
get listening() {
|
|
8795
|
+
return this.server !== void 0 && this.server.listening;
|
|
8796
|
+
}
|
|
8797
|
+
async start() {
|
|
8798
|
+
if (this.server) return;
|
|
8799
|
+
await new Promise((resolve, reject) => {
|
|
8800
|
+
const server = net3.createServer((socket) => this.handleConnection(socket));
|
|
8801
|
+
const onError = (err) => {
|
|
8802
|
+
server.removeListener("error", onError);
|
|
8803
|
+
reject(err);
|
|
8804
|
+
};
|
|
8805
|
+
server.once("error", onError);
|
|
8806
|
+
server.listen(this.listenPort, this.listenHost, () => {
|
|
8807
|
+
server.removeListener("error", onError);
|
|
8808
|
+
this.server = server;
|
|
8809
|
+
this.logger.info?.(
|
|
8810
|
+
`[BaichuanRtspBackchannelServer] listening on ${this.listenHost}:${this.listenPort} path=${this.path}`
|
|
8811
|
+
);
|
|
8812
|
+
resolve();
|
|
8813
|
+
});
|
|
8814
|
+
});
|
|
8815
|
+
}
|
|
8816
|
+
async stop() {
|
|
8817
|
+
const server = this.server;
|
|
8818
|
+
this.server = void 0;
|
|
8819
|
+
for (const session of this.sessionByClient.values()) {
|
|
8820
|
+
this.sessionByClient.delete(session.clientId);
|
|
8821
|
+
if (session.handler) {
|
|
8822
|
+
void session.handler.stop();
|
|
8823
|
+
}
|
|
8824
|
+
try {
|
|
8825
|
+
session.socket.destroy();
|
|
8826
|
+
} catch {
|
|
8827
|
+
}
|
|
8828
|
+
}
|
|
8829
|
+
if (!server) return;
|
|
8830
|
+
await new Promise((resolve) => {
|
|
8831
|
+
server.close(() => resolve());
|
|
8832
|
+
});
|
|
8833
|
+
}
|
|
8834
|
+
handleConnection(socket) {
|
|
8835
|
+
const clientId = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
8836
|
+
const connectedAt = Date.now();
|
|
8837
|
+
let buffer = Buffer.alloc(0);
|
|
8838
|
+
this.logger.info?.(
|
|
8839
|
+
`[BaichuanRtspBackchannelServer] client connected client=${clientId} path=${this.path}`
|
|
8840
|
+
);
|
|
8841
|
+
this.emit("client", clientId);
|
|
8842
|
+
const cleanup = () => {
|
|
8843
|
+
const session = this.sessionByClient.get(clientId);
|
|
8844
|
+
const durationMs = Date.now() - connectedAt;
|
|
8845
|
+
if (session) {
|
|
8846
|
+
this.sessionByClient.delete(clientId);
|
|
8847
|
+
if (session.handler) {
|
|
8848
|
+
const stats = session.handler.stats;
|
|
8849
|
+
this.logger.info?.(
|
|
8850
|
+
`[BaichuanRtspBackchannelServer] client disconnected client=${clientId} session=${session.sessionId} duration=${durationMs}ms rtpRx=${stats.rtpPacketsReceived} rtpDropped=${stats.rtpPacketsDropped} adpcmBlocks=${stats.adpcmBlocksSent}`
|
|
8851
|
+
);
|
|
8852
|
+
void session.handler.stop();
|
|
8853
|
+
} else {
|
|
8854
|
+
this.logger.info?.(
|
|
8855
|
+
`[BaichuanRtspBackchannelServer] client disconnected client=${clientId} session=${session.sessionId} duration=${durationMs}ms (no RECORD)`
|
|
8856
|
+
);
|
|
8857
|
+
}
|
|
8858
|
+
} else {
|
|
8859
|
+
this.logger.info?.(
|
|
8860
|
+
`[BaichuanRtspBackchannelServer] client disconnected client=${clientId} duration=${durationMs}ms (no SETUP)`
|
|
8861
|
+
);
|
|
8862
|
+
}
|
|
8863
|
+
this.nonces.delete(clientId);
|
|
8864
|
+
this.emit("clientDisconnected", clientId);
|
|
8865
|
+
};
|
|
8866
|
+
socket.on("close", cleanup);
|
|
8867
|
+
socket.on("error", (err) => {
|
|
8868
|
+
this.logger.warn?.(
|
|
8869
|
+
`[BaichuanRtspBackchannelServer] socket error client=${clientId}: ${err.message}`
|
|
8870
|
+
);
|
|
8871
|
+
cleanup();
|
|
8872
|
+
});
|
|
8873
|
+
socket.on("data", (data) => {
|
|
8874
|
+
buffer = Buffer.concat([buffer, data]);
|
|
8875
|
+
this.drainBuffer(socket, clientId, (b) => {
|
|
8876
|
+
buffer = b;
|
|
8877
|
+
}, () => buffer);
|
|
8878
|
+
});
|
|
8879
|
+
}
|
|
8880
|
+
/**
|
|
8881
|
+
* Drain pending bytes from the per-client buffer. Each iteration first
|
|
8882
|
+
* peels off any TCP-interleaved `$` frames at the head (routing them to
|
|
8883
|
+
* the session's RtspBackchannel handler when their channel matches) and
|
|
8884
|
+
* then attempts to parse a complete RTSP request.
|
|
8885
|
+
*
|
|
8886
|
+
* Implemented as a thin loop so the parser can yield back to the event
|
|
8887
|
+
* loop when the buffer is partial — no async work between frames keeps
|
|
8888
|
+
* the data path tight.
|
|
8889
|
+
*/
|
|
8890
|
+
drainBuffer(socket, clientId, setBuffer, getBuffer) {
|
|
8891
|
+
const tryDrainInterleaved = () => {
|
|
8892
|
+
let consumed = false;
|
|
8893
|
+
while (true) {
|
|
8894
|
+
const buf = getBuffer();
|
|
8895
|
+
if (buf.length === 0 || buf[0] !== 36) return consumed;
|
|
8896
|
+
if (buf.length < 4) return consumed;
|
|
8897
|
+
const channel = buf[1] ?? 0;
|
|
8898
|
+
const len = buf.readUInt16BE(2);
|
|
8899
|
+
if (buf.length < 4 + len) return consumed;
|
|
8900
|
+
const rtpPacket = buf.subarray(4, 4 + len);
|
|
8901
|
+
setBuffer(buf.subarray(4 + len));
|
|
8902
|
+
consumed = true;
|
|
8903
|
+
const session = this.sessionByClient.get(clientId);
|
|
8904
|
+
if (session && session.rtpChannel === channel && session.handler) {
|
|
8905
|
+
session.handler.feedRtp(Buffer.from(rtpPacket));
|
|
8906
|
+
}
|
|
8907
|
+
}
|
|
8908
|
+
};
|
|
8909
|
+
void (async () => {
|
|
8910
|
+
while (true) {
|
|
8911
|
+
tryDrainInterleaved();
|
|
8912
|
+
const buf = getBuffer();
|
|
8913
|
+
if (buf.length === 0) return;
|
|
8914
|
+
if (buf[0] === 36) return;
|
|
8915
|
+
const headerEnd = buf.indexOf("\r\n\r\n");
|
|
8916
|
+
if (headerEnd < 0) return;
|
|
8917
|
+
const requestText = buf.subarray(0, headerEnd).toString("utf8");
|
|
8918
|
+
setBuffer(buf.subarray(headerEnd + 4));
|
|
8919
|
+
try {
|
|
8920
|
+
await this.handleRequest(socket, clientId, requestText);
|
|
8921
|
+
} catch (e) {
|
|
8922
|
+
this.logger.warn?.(
|
|
8923
|
+
`[BaichuanRtspBackchannelServer] handleRequest failed for ${clientId}: ${e.message}`
|
|
8924
|
+
);
|
|
8925
|
+
try {
|
|
8926
|
+
socket.destroy();
|
|
8927
|
+
} catch {
|
|
8928
|
+
}
|
|
8929
|
+
return;
|
|
8930
|
+
}
|
|
8931
|
+
}
|
|
8932
|
+
})();
|
|
8933
|
+
}
|
|
8934
|
+
async handleRequest(socket, clientId, requestText) {
|
|
8935
|
+
const lines = requestText.split("\r\n");
|
|
8936
|
+
const head = lines[0]?.split(" ") ?? [];
|
|
8937
|
+
if (head.length < 3) {
|
|
8938
|
+
this.logger.warn?.(
|
|
8939
|
+
`[BaichuanRtspBackchannelServer] malformed request from ${clientId}: "${(lines[0] ?? "").slice(0, 120)}"`
|
|
8940
|
+
);
|
|
8941
|
+
return;
|
|
8942
|
+
}
|
|
8943
|
+
const method = head[0] ?? "";
|
|
8944
|
+
const url = head[1] ?? "";
|
|
8945
|
+
const protocol = head[2] ?? "RTSP/1.0";
|
|
8946
|
+
const cseqMatch = requestText.match(/CSeq:\s*(\d+)/i);
|
|
8947
|
+
const cseq = cseqMatch ? parseInt(cseqMatch[1] ?? "0", 10) : 0;
|
|
8948
|
+
const sessionMatch = requestText.match(/Session:\s*([^;\r\n]+)/i);
|
|
8949
|
+
let sessionId = sessionMatch?.[1]?.trim();
|
|
8950
|
+
const userAgent = requestText.match(/User-Agent:\s*([^\r\n]+)/i)?.[1]?.trim();
|
|
8951
|
+
this.logger.info?.(
|
|
8952
|
+
`[BaichuanRtspBackchannelServer] >> ${method} ${url} client=${clientId} cseq=${cseq}` + (sessionId ? ` session=${sessionId}` : "") + (userAgent ? ` ua="${userAgent}"` : "")
|
|
8953
|
+
);
|
|
8954
|
+
const send = (status, reason, headers = {}, body) => {
|
|
8955
|
+
let r = `${protocol} ${status} ${reason}\r
|
|
8956
|
+
CSeq: ${cseq}\r
|
|
8957
|
+
`;
|
|
8958
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
8959
|
+
r += `${k}: ${v}\r
|
|
8960
|
+
`;
|
|
8961
|
+
}
|
|
8962
|
+
if (body) {
|
|
8963
|
+
const buf = Buffer.from(body, "utf8");
|
|
8964
|
+
r += `Content-Length: ${buf.length}\r
|
|
8965
|
+
\r
|
|
8966
|
+
`;
|
|
8967
|
+
socket.write(r);
|
|
8968
|
+
socket.write(buf);
|
|
8969
|
+
} else {
|
|
8970
|
+
r += "\r\n";
|
|
8971
|
+
socket.write(r);
|
|
8972
|
+
}
|
|
8973
|
+
const headerSummary = Object.entries(headers).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
8974
|
+
this.logger.info?.(
|
|
8975
|
+
`[BaichuanRtspBackchannelServer] << ${status} ${reason} client=${clientId} cseq=${cseq}` + (headerSummary ? ` [${headerSummary}]` : "") + (body ? ` body=${body.length}B` : "")
|
|
8976
|
+
);
|
|
8977
|
+
};
|
|
8978
|
+
if (this.requireAuth && method !== "OPTIONS") {
|
|
8979
|
+
const authHeader = requestText.match(/Authorization:\s*([^\r\n]+)/i)?.[1] ?? "";
|
|
8980
|
+
if (!authHeader) {
|
|
8981
|
+
this.logger.info?.(
|
|
8982
|
+
`[BaichuanRtspBackchannelServer] auth challenge issued client=${clientId} method=${method}`
|
|
8983
|
+
);
|
|
8984
|
+
send(401, "Unauthorized", {
|
|
8985
|
+
"WWW-Authenticate": this.wwwAuthenticate(clientId)
|
|
8986
|
+
});
|
|
8987
|
+
return;
|
|
8988
|
+
}
|
|
8989
|
+
if (!this.validateDigest(authHeader, method, url, clientId)) {
|
|
8990
|
+
this.logger.warn?.(
|
|
8991
|
+
`[BaichuanRtspBackchannelServer] auth rejected client=${clientId} method=${method}`
|
|
8992
|
+
);
|
|
8993
|
+
this.nonces.delete(clientId);
|
|
8994
|
+
send(401, "Unauthorized", {
|
|
8995
|
+
"WWW-Authenticate": this.wwwAuthenticate(clientId)
|
|
8996
|
+
});
|
|
8997
|
+
return;
|
|
8998
|
+
}
|
|
8999
|
+
}
|
|
9000
|
+
switch (method) {
|
|
9001
|
+
case "OPTIONS":
|
|
9002
|
+
send(200, "OK", {
|
|
9003
|
+
Public: "OPTIONS, DESCRIBE, SETUP, RECORD, TEARDOWN"
|
|
9004
|
+
});
|
|
9005
|
+
return;
|
|
9006
|
+
case "DESCRIBE": {
|
|
9007
|
+
const sdp = this.buildSdp();
|
|
9008
|
+
this.logger.debug?.(
|
|
9009
|
+
`[BaichuanRtspBackchannelServer] DESCRIBE sdp for ${clientId}:
|
|
9010
|
+
${sdp.trimEnd()}`
|
|
9011
|
+
);
|
|
9012
|
+
send(
|
|
9013
|
+
200,
|
|
9014
|
+
"OK",
|
|
9015
|
+
{
|
|
9016
|
+
"Content-Type": "application/sdp",
|
|
9017
|
+
"Content-Base": `rtsp://${this.listenHost}:${this.listenPort}${this.path}/`
|
|
9018
|
+
},
|
|
9019
|
+
sdp
|
|
9020
|
+
);
|
|
9021
|
+
return;
|
|
9022
|
+
}
|
|
9023
|
+
case "SETUP": {
|
|
9024
|
+
if (!url.includes("audiobackchannel")) {
|
|
9025
|
+
this.logger.warn?.(
|
|
9026
|
+
`[BaichuanRtspBackchannelServer] SETUP rejected (unknown track) client=${clientId} url=${url}`
|
|
9027
|
+
);
|
|
9028
|
+
send(404, "Not Found");
|
|
9029
|
+
return;
|
|
9030
|
+
}
|
|
9031
|
+
const transportLine = requestText.match(/Transport:\s*([^\r\n]+)/i)?.[1] ?? "";
|
|
9032
|
+
this.logger.info?.(
|
|
9033
|
+
`[BaichuanRtspBackchannelServer] SETUP transport="${transportLine}" client=${clientId}`
|
|
9034
|
+
);
|
|
9035
|
+
if (!transportLine.toUpperCase().includes("RTP/AVP/TCP") && !transportLine.toLowerCase().includes("interleaved")) {
|
|
9036
|
+
this.logger.warn?.(
|
|
9037
|
+
`[BaichuanRtspBackchannelServer] SETUP rejected (non-TCP transport) client=${clientId}`
|
|
9038
|
+
);
|
|
9039
|
+
send(461, "Unsupported Transport");
|
|
9040
|
+
return;
|
|
9041
|
+
}
|
|
9042
|
+
const interleaved = parseInterleavedChannels(transportLine) ?? {
|
|
9043
|
+
rtp: 0,
|
|
9044
|
+
rtcp: 1
|
|
9045
|
+
};
|
|
9046
|
+
if (!sessionId) {
|
|
9047
|
+
sessionId = newSessionId();
|
|
9048
|
+
}
|
|
9049
|
+
this.sessionByClient.set(clientId, {
|
|
9050
|
+
sessionId,
|
|
9051
|
+
clientId,
|
|
9052
|
+
socket,
|
|
9053
|
+
rtpChannel: interleaved.rtp,
|
|
9054
|
+
rtcpChannel: interleaved.rtcp
|
|
9055
|
+
});
|
|
9056
|
+
this.logger.info?.(
|
|
9057
|
+
`[BaichuanRtspBackchannelServer] SETUP ok client=${clientId} session=${sessionId} interleaved=${interleaved.rtp}-${interleaved.rtcp}`
|
|
9058
|
+
);
|
|
9059
|
+
send(200, "OK", {
|
|
9060
|
+
Transport: `RTP/AVP/TCP;unicast;interleaved=${interleaved.rtp}-${interleaved.rtcp};mode=record`,
|
|
9061
|
+
Session: sessionId
|
|
9062
|
+
});
|
|
9063
|
+
return;
|
|
9064
|
+
}
|
|
9065
|
+
case "RECORD": {
|
|
9066
|
+
const session = sessionId ? Array.from(this.sessionByClient.values()).find(
|
|
9067
|
+
(s) => s.sessionId === sessionId
|
|
9068
|
+
) : this.sessionByClient.get(clientId);
|
|
9069
|
+
if (!session) {
|
|
9070
|
+
this.logger.warn?.(
|
|
9071
|
+
`[BaichuanRtspBackchannelServer] RECORD without active session client=${clientId} requestedSession=${sessionId ?? "(none)"}`
|
|
9072
|
+
);
|
|
9073
|
+
send(454, "Session Not Found");
|
|
9074
|
+
return;
|
|
9075
|
+
}
|
|
9076
|
+
if (session.handler) {
|
|
9077
|
+
this.logger.info?.(
|
|
9078
|
+
`[BaichuanRtspBackchannelServer] RECORD idempotent (already recording) client=${clientId} session=${session.sessionId}`
|
|
9079
|
+
);
|
|
9080
|
+
send(200, "OK", { Session: session.sessionId });
|
|
9081
|
+
return;
|
|
9082
|
+
}
|
|
9083
|
+
const apiRef = this.api;
|
|
9084
|
+
const channelForCamera = this.channel;
|
|
9085
|
+
const loggerRef = this.logger;
|
|
9086
|
+
const deviceIdRef = this.deviceId ?? `rtsp-backchannel-${clientId}`;
|
|
9087
|
+
const handler = new RtspBackchannel({
|
|
9088
|
+
openTalkSession: () => apiRef.createDedicatedTalkSession(channelForCamera, {
|
|
9089
|
+
deviceId: deviceIdRef,
|
|
9090
|
+
logger: loggerRef
|
|
9091
|
+
}),
|
|
9092
|
+
logger: loggerRef
|
|
9093
|
+
});
|
|
9094
|
+
const recordStart = Date.now();
|
|
9095
|
+
this.logger.info?.(
|
|
9096
|
+
`[BaichuanRtspBackchannelServer] RECORD opening TalkSession client=${clientId} session=${session.sessionId} channel=${channelForCamera} deviceId="${deviceIdRef}"`
|
|
9097
|
+
);
|
|
9098
|
+
try {
|
|
9099
|
+
const talk = await handler.start();
|
|
9100
|
+
this.logger.info?.(
|
|
9101
|
+
`[BaichuanRtspBackchannelServer] RECORD talk session ready client=${clientId} session=${session.sessionId} blockSize=${talk.info.blockSize} fullBlockSize=${talk.info.fullBlockSize} sampleRate=${talk.info.audioConfig.sampleRate} audioType=${talk.info.audioConfig.audioType} duplex setupMs=${Date.now() - recordStart}`
|
|
9102
|
+
);
|
|
9103
|
+
} catch (e) {
|
|
9104
|
+
this.logger.warn?.(
|
|
9105
|
+
`[BaichuanRtspBackchannelServer] RECORD failed to open TalkSession client=${clientId} session=${session.sessionId} setupMs=${Date.now() - recordStart} error="${e.message}"`
|
|
9106
|
+
);
|
|
9107
|
+
send(503, "Service Unavailable");
|
|
9108
|
+
return;
|
|
9109
|
+
}
|
|
9110
|
+
session.handler = handler;
|
|
9111
|
+
send(200, "OK", { Session: session.sessionId });
|
|
9112
|
+
return;
|
|
9113
|
+
}
|
|
9114
|
+
case "TEARDOWN": {
|
|
9115
|
+
const session = this.sessionByClient.get(clientId);
|
|
9116
|
+
if (session) {
|
|
9117
|
+
this.sessionByClient.delete(clientId);
|
|
9118
|
+
if (session.handler) {
|
|
9119
|
+
const stats = session.handler.stats;
|
|
9120
|
+
this.logger.info?.(
|
|
9121
|
+
`[BaichuanRtspBackchannelServer] TEARDOWN client=${clientId} session=${session.sessionId} rtpRx=${stats.rtpPacketsReceived} rtpDropped=${stats.rtpPacketsDropped} adpcmBlocks=${stats.adpcmBlocksSent}`
|
|
9122
|
+
);
|
|
9123
|
+
void session.handler.stop();
|
|
9124
|
+
} else {
|
|
9125
|
+
this.logger.info?.(
|
|
9126
|
+
`[BaichuanRtspBackchannelServer] TEARDOWN client=${clientId} session=${session.sessionId} (no RECORD)`
|
|
9127
|
+
);
|
|
9128
|
+
}
|
|
9129
|
+
} else {
|
|
9130
|
+
this.logger.info?.(
|
|
9131
|
+
`[BaichuanRtspBackchannelServer] TEARDOWN client=${clientId} (no active session)`
|
|
9132
|
+
);
|
|
9133
|
+
}
|
|
9134
|
+
send(200, "OK", { Session: sessionId ?? "" });
|
|
9135
|
+
try {
|
|
9136
|
+
socket.end();
|
|
9137
|
+
} catch {
|
|
9138
|
+
}
|
|
9139
|
+
return;
|
|
9140
|
+
}
|
|
9141
|
+
default:
|
|
9142
|
+
this.logger.warn?.(
|
|
9143
|
+
`[BaichuanRtspBackchannelServer] unknown method ${method} from ${clientId} \u2014 replying 501`
|
|
9144
|
+
);
|
|
9145
|
+
send(501, "Not Implemented");
|
|
9146
|
+
}
|
|
9147
|
+
}
|
|
9148
|
+
buildSdp() {
|
|
9149
|
+
return `v=0\r
|
|
9150
|
+
o=- ${Date.now()} ${Date.now()} IN IP4 ${this.listenHost}\r
|
|
9151
|
+
s=Baichuan Backchannel\r
|
|
9152
|
+
c=IN IP4 ${this.listenHost}\r
|
|
9153
|
+
t=0 0\r
|
|
9154
|
+
a=control:*\r
|
|
9155
|
+
m=audio 0 RTP/AVP 0\r
|
|
9156
|
+
a=rtpmap:0 PCMU/8000\r
|
|
9157
|
+
a=sendonly\r
|
|
9158
|
+
a=control:audiobackchannel\r
|
|
9159
|
+
`;
|
|
9160
|
+
}
|
|
9161
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
9162
|
+
// Digest auth helpers (mirror BaichuanRtspServer's behaviour so
|
|
9163
|
+
// operators can re-use the same credentials store across both servers).
|
|
9164
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
9165
|
+
wwwAuthenticate(clientId) {
|
|
9166
|
+
const nonce = this.nonceFor(clientId);
|
|
9167
|
+
return `Digest realm="${this.authRealm}", nonce="${nonce}"`;
|
|
9168
|
+
}
|
|
9169
|
+
nonceFor(clientId) {
|
|
9170
|
+
const now = Date.now();
|
|
9171
|
+
const existing = this.nonces.get(clientId);
|
|
9172
|
+
if (existing && now - existing.ts < _BaichuanRtspBackchannelServer.NONCE_TTL_MS) {
|
|
9173
|
+
return existing.nonce;
|
|
9174
|
+
}
|
|
9175
|
+
const nonce = crypto2.randomBytes(16).toString("hex");
|
|
9176
|
+
this.nonces.set(clientId, { nonce, ts: now });
|
|
9177
|
+
return nonce;
|
|
9178
|
+
}
|
|
9179
|
+
validateDigest(header, method, uri, clientId) {
|
|
9180
|
+
const params = parseDigestHeader(header);
|
|
9181
|
+
if (!params) return false;
|
|
9182
|
+
if (params.realm && params.realm !== this.authRealm) return false;
|
|
9183
|
+
const nonce = this.nonces.get(clientId);
|
|
9184
|
+
if (!nonce || nonce.nonce !== params.nonce) return false;
|
|
9185
|
+
if (Date.now() - nonce.ts > _BaichuanRtspBackchannelServer.NONCE_TTL_MS)
|
|
9186
|
+
return false;
|
|
9187
|
+
const cred = this.authCredentials.find((c) => c.username === params.username);
|
|
9188
|
+
if (!cred) return false;
|
|
9189
|
+
const ha1 = cred.ha1 ?? (cred.password !== void 0 ? md5Hex(`${cred.username}:${this.authRealm}:${cred.password}`) : void 0);
|
|
9190
|
+
if (!ha1) return false;
|
|
9191
|
+
const ha2 = md5Hex(`${method}:${params.uri || uri}`);
|
|
9192
|
+
const expected = md5Hex(`${ha1}:${params.nonce}:${ha2}`);
|
|
9193
|
+
return expected === params.response;
|
|
9194
|
+
}
|
|
9195
|
+
};
|
|
9196
|
+
function newSessionId() {
|
|
9197
|
+
return `talk_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
9198
|
+
}
|
|
9199
|
+
function parseInterleavedChannels(transport) {
|
|
9200
|
+
const m = transport.match(/interleaved\s*=\s*(\d+)\s*-\s*(\d+)/i);
|
|
9201
|
+
if (!m) return void 0;
|
|
9202
|
+
const rtp = parseInt(m[1] ?? "", 10);
|
|
9203
|
+
const rtcp = parseInt(m[2] ?? "", 10);
|
|
9204
|
+
if (!Number.isFinite(rtp) || !Number.isFinite(rtcp)) return void 0;
|
|
9205
|
+
return { rtp, rtcp };
|
|
9206
|
+
}
|
|
9207
|
+
function parseDigestHeader(header) {
|
|
9208
|
+
if (!/^digest\s+/i.test(header)) return null;
|
|
9209
|
+
const params = {};
|
|
9210
|
+
const re = /(\w+)\s*=\s*("([^"]*)"|([^,\s]+))/g;
|
|
9211
|
+
let match;
|
|
9212
|
+
while ((match = re.exec(header)) !== null) {
|
|
9213
|
+
const key = (match[1] ?? "").toLowerCase();
|
|
9214
|
+
const value = match[3] ?? match[4] ?? "";
|
|
9215
|
+
params[key] = value;
|
|
9216
|
+
}
|
|
9217
|
+
if (!params.username || !params.nonce || !params.uri || !params.response) {
|
|
9218
|
+
return null;
|
|
9219
|
+
}
|
|
9220
|
+
return {
|
|
9221
|
+
username: params.username,
|
|
9222
|
+
realm: params.realm ?? "",
|
|
9223
|
+
nonce: params.nonce,
|
|
9224
|
+
uri: params.uri,
|
|
9225
|
+
response: params.response
|
|
9226
|
+
};
|
|
9227
|
+
}
|
|
9228
|
+
|
|
8288
9229
|
// src/emailPush/server.ts
|
|
8289
9230
|
import { format as utilFormat } from "util";
|
|
8290
9231
|
import { SMTPServer } from "smtp-server";
|
|
@@ -8747,6 +9688,7 @@ export {
|
|
|
8747
9688
|
BaichuanHlsServer,
|
|
8748
9689
|
BaichuanHttpStreamServer,
|
|
8749
9690
|
BaichuanMjpegServer,
|
|
9691
|
+
BaichuanRtspBackchannelServer,
|
|
8750
9692
|
BaichuanRtspServer,
|
|
8751
9693
|
BaichuanVideoStream,
|
|
8752
9694
|
BaichuanWebRTCServer,
|
|
@@ -8772,10 +9714,12 @@ export {
|
|
|
8772
9714
|
ReolinkCgiApi,
|
|
8773
9715
|
ReolinkHttpClient,
|
|
8774
9716
|
Rfc4571Muxer,
|
|
9717
|
+
RtspBackchannel,
|
|
8775
9718
|
_resetEmailPushBusForTests,
|
|
8776
9719
|
abilitiesHasAny,
|
|
8777
9720
|
aesDecrypt,
|
|
8778
9721
|
aesEncrypt,
|
|
9722
|
+
alawToPcm16,
|
|
8779
9723
|
applyStreamPatch,
|
|
8780
9724
|
applyXmlTagPatch,
|
|
8781
9725
|
asLogger,
|
|
@@ -8848,6 +9792,7 @@ export {
|
|
|
8848
9792
|
discoverViaUdpDirect,
|
|
8849
9793
|
emitEmailPushEvent,
|
|
8850
9794
|
encodeHeader,
|
|
9795
|
+
encodeImaAdpcm,
|
|
8851
9796
|
encodeMotionScopeBitmap,
|
|
8852
9797
|
encodeMotionSensitivityListXml,
|
|
8853
9798
|
encodeShelterCoord,
|
|
@@ -8890,6 +9835,7 @@ export {
|
|
|
8890
9835
|
maskUid,
|
|
8891
9836
|
md5HexUpper,
|
|
8892
9837
|
md5StrModern,
|
|
9838
|
+
mulawToPcm16,
|
|
8893
9839
|
normalizeDayNightMode,
|
|
8894
9840
|
normalizeOpenClose,
|
|
8895
9841
|
normalizeUid,
|
|
@@ -8901,6 +9847,7 @@ export {
|
|
|
8901
9847
|
parseAdtsHeader,
|
|
8902
9848
|
parseBcMedia,
|
|
8903
9849
|
parseRecordingFileName,
|
|
9850
|
+
parseRtpPacket,
|
|
8904
9851
|
parseSupportXml,
|
|
8905
9852
|
patchAiDetectCfgXml,
|
|
8906
9853
|
patchMotionSensitivityListXml,
|
|
@@ -8918,6 +9865,7 @@ export {
|
|
|
8918
9865
|
splitAnnexBToNals,
|
|
8919
9866
|
splitAnnexBToNalPayloads2 as splitH265AnnexBToNalPayloads,
|
|
8920
9867
|
testChannelStreams,
|
|
9868
|
+
upsamplePcm16,
|
|
8921
9869
|
upsertXmlTag,
|
|
8922
9870
|
xmlEscape,
|
|
8923
9871
|
xmlIndicatesFloodlight,
|