@camstack/addon-pipeline 1.2.258 → 1.2.259
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/pipeline-runner/index.js +1 -1
- package/dist/pipeline-runner/index.mjs +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/{restream-intent-CgyZI565.mjs → restream-intent-CW79dvFV.mjs} +35 -3
- package/dist/{restream-intent-B0GOuR-F.js → restream-intent-iKGCB9IR.js} +46 -2
- package/dist/stream-broker/index.js +431 -11
- package/dist/stream-broker/index.mjs +431 -11
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ const require_default_detection_model = require("../default-detection-model-vVWc
|
|
|
9
9
|
const require_worker_protocol = require("../worker-protocol-B2MfQLlu.js");
|
|
10
10
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
11
11
|
const require_lazy_sharp = require("../lazy-sharp-BuwKlQLR.js");
|
|
12
|
-
const require_restream_intent = require("../restream-intent-
|
|
12
|
+
const require_restream_intent = require("../restream-intent-iKGCB9IR.js");
|
|
13
13
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
14
14
|
let node_os = require("node:os");
|
|
15
15
|
node_os = require_chunk.__toESM(node_os);
|
|
@@ -4,7 +4,7 @@ import { a as localFrameRegistry, d as getStepDefinition, i as rootObjectGate, o
|
|
|
4
4
|
import { t as isWorkerReply } from "../worker-protocol-C_W-P_g-.mjs";
|
|
5
5
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
6
6
|
import { t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { c as withDetectionIntent, o as preRollRequestOf, t as DETECTION_PRE_ROLL_REQUEST_MS } from "../restream-intent-CW79dvFV.mjs";
|
|
8
8
|
import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import { fork } from "node:child_process";
|
package/dist/recorder/index.js
CHANGED
|
@@ -6,7 +6,7 @@ const require_chunk = require("../chunk-emK7D4bc.js");
|
|
|
6
6
|
const require_dist = require("../dist-CsJJPDQW.js");
|
|
7
7
|
const require_node = require("../node-1vbEWzPZ.js");
|
|
8
8
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
9
|
-
const require_restream_intent = require("../restream-intent-
|
|
9
|
+
const require_restream_intent = require("../restream-intent-iKGCB9IR.js");
|
|
10
10
|
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
11
11
|
let node_crypto = require("node:crypto");
|
|
12
12
|
let node_fs = require("node:fs");
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-D3w2fwD4.mjs";
|
|
2
2
|
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-IHGNrWH9.mjs";
|
|
3
3
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { d as PREBUFFER_MAX_MS, l as withRecordingIntent, r as RECORDING_PRE_ROLL_REQUEST_MS } from "../restream-intent-CW79dvFV.mjs";
|
|
5
5
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { createReadStream, promises, realpathSync } from "node:fs";
|
|
@@ -128,7 +128,10 @@ var RECORDING_PATH_SUFFIX = "/recording";
|
|
|
128
128
|
* Every profile gets one, exactly as every profile gets a `/muted`: a consumer
|
|
129
129
|
* dials `<token>/sentry` instead of `<token>` and is asking for the stream
|
|
130
130
|
* WITHOUT asking for the camera. It is fed the last picture while the camera
|
|
131
|
-
* sleeps, and the real media the moment something wakes it.
|
|
131
|
+
* sleeps, and the real media the moment something wakes it — on BOTH planes.
|
|
132
|
+
* The audio track is announced and filled with silence for as long as the
|
|
133
|
+
* picture is being served, and the camera's own audio takes it over the instant
|
|
134
|
+
* media flows (D525). Only the placeholder is mute; the path never was.
|
|
132
135
|
*
|
|
133
136
|
* A separate path and not a flag, for the reason `/muted` is one: the choice
|
|
134
137
|
* belongs to the URL a consumer was given, so a Frigate configured once keeps
|
|
@@ -234,7 +237,7 @@ function parseRestreamPath(streamPath) {
|
|
|
234
237
|
});
|
|
235
238
|
if (rest.endsWith("/sentry")) return {
|
|
236
239
|
lookupPath: rest.slice(0, -7),
|
|
237
|
-
muted:
|
|
240
|
+
muted: false,
|
|
238
241
|
declaration: declare("sentry")
|
|
239
242
|
};
|
|
240
243
|
if (rest.endsWith("/recording")) return {
|
|
@@ -258,6 +261,35 @@ function parseRestreamPath(streamPath) {
|
|
|
258
261
|
declaration: NO_DECLARATION
|
|
259
262
|
};
|
|
260
263
|
}
|
|
264
|
+
/** The variant this dial is answered with. */
|
|
265
|
+
function sdpVariantFor(parsed) {
|
|
266
|
+
if (parsed.declaration.intent === "sentry") return "sentry";
|
|
267
|
+
return parsed.muted ? "muted" : "full";
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Whether this dial is a RE-CLOCKING leg, which may only join at the live edge.
|
|
271
|
+
*
|
|
272
|
+
* `isMuted()` used to answer two unrelated questions at once, and un-muting the
|
|
273
|
+
* sentry made the conflation load-bearing:
|
|
274
|
+
*
|
|
275
|
+
* - "serve this session no audio" — what the word means, and all it means now;
|
|
276
|
+
* - "this consumer re-emits what it receives on a real-time clock, has ~zero
|
|
277
|
+
* decode headroom, and never drains a 2-3 s catch-up burst" — the rule
|
|
278
|
+
* `LIVE_EDGE_MAX_SPAN_MS` was measured for (a HomeKit leg that is an ffmpeg
|
|
279
|
+
* copy of this restream).
|
|
280
|
+
*
|
|
281
|
+
* The two coincided only because every consumer that re-clocked also happened
|
|
282
|
+
* to be video-only. A sentry is the counter-example: it re-clocks exactly like
|
|
283
|
+
* the `/muted` legs — it is the same ffmpeg-copy shape — and it now carries
|
|
284
|
+
* audio. So the join rule asks THIS, not the mute, and every population keeps
|
|
285
|
+
* its measured behaviour byte-for-byte: `/muted` yes, `/sentry` yes, detection
|
|
286
|
+
* no (it drains the burst by construction — D193), recording and plain no.
|
|
287
|
+
*/
|
|
288
|
+
function isLiveEdgeOnlyDial(dial) {
|
|
289
|
+
if (dial.declaration.intent === "detection") return false;
|
|
290
|
+
if (dial.declaration.intent === "sentry") return true;
|
|
291
|
+
return dial.muted;
|
|
292
|
+
}
|
|
261
293
|
/**
|
|
262
294
|
* The amount a dial URL asks for, read back off the URL itself.
|
|
263
295
|
*
|
|
@@ -334,4 +366,4 @@ function withSentryIntent(restreamUrl) {
|
|
|
334
366
|
return `${rest}${SENTRY_PATH_SUFFIX}`;
|
|
335
367
|
}
|
|
336
368
|
//#endregion
|
|
337
|
-
export {
|
|
369
|
+
export { parseRestreamPath as a, withDetectionIntent as c, PREBUFFER_MAX_MS as d, capPreRollRequestMs as f, isLiveEdgeOnlyDial as i, withRecordingIntent as l, resolvePrebuffer as m, NO_DECLARATION as n, preRollRequestOf as o, prebufferRetentionMs as p, RECORDING_PRE_ROLL_REQUEST_MS as r, sdpVariantFor as s, DETECTION_PRE_ROLL_REQUEST_MS as t, withSentryIntent as u };
|
|
@@ -128,7 +128,10 @@ var RECORDING_PATH_SUFFIX = "/recording";
|
|
|
128
128
|
* Every profile gets one, exactly as every profile gets a `/muted`: a consumer
|
|
129
129
|
* dials `<token>/sentry` instead of `<token>` and is asking for the stream
|
|
130
130
|
* WITHOUT asking for the camera. It is fed the last picture while the camera
|
|
131
|
-
* sleeps, and the real media the moment something wakes it.
|
|
131
|
+
* sleeps, and the real media the moment something wakes it — on BOTH planes.
|
|
132
|
+
* The audio track is announced and filled with silence for as long as the
|
|
133
|
+
* picture is being served, and the camera's own audio takes it over the instant
|
|
134
|
+
* media flows (D525). Only the placeholder is mute; the path never was.
|
|
132
135
|
*
|
|
133
136
|
* A separate path and not a flag, for the reason `/muted` is one: the choice
|
|
134
137
|
* belongs to the URL a consumer was given, so a Frigate configured once keeps
|
|
@@ -234,7 +237,7 @@ function parseRestreamPath(streamPath) {
|
|
|
234
237
|
});
|
|
235
238
|
if (rest.endsWith("/sentry")) return {
|
|
236
239
|
lookupPath: rest.slice(0, -7),
|
|
237
|
-
muted:
|
|
240
|
+
muted: false,
|
|
238
241
|
declaration: declare("sentry")
|
|
239
242
|
};
|
|
240
243
|
if (rest.endsWith("/recording")) return {
|
|
@@ -258,6 +261,35 @@ function parseRestreamPath(streamPath) {
|
|
|
258
261
|
declaration: NO_DECLARATION
|
|
259
262
|
};
|
|
260
263
|
}
|
|
264
|
+
/** The variant this dial is answered with. */
|
|
265
|
+
function sdpVariantFor(parsed) {
|
|
266
|
+
if (parsed.declaration.intent === "sentry") return "sentry";
|
|
267
|
+
return parsed.muted ? "muted" : "full";
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Whether this dial is a RE-CLOCKING leg, which may only join at the live edge.
|
|
271
|
+
*
|
|
272
|
+
* `isMuted()` used to answer two unrelated questions at once, and un-muting the
|
|
273
|
+
* sentry made the conflation load-bearing:
|
|
274
|
+
*
|
|
275
|
+
* - "serve this session no audio" — what the word means, and all it means now;
|
|
276
|
+
* - "this consumer re-emits what it receives on a real-time clock, has ~zero
|
|
277
|
+
* decode headroom, and never drains a 2-3 s catch-up burst" — the rule
|
|
278
|
+
* `LIVE_EDGE_MAX_SPAN_MS` was measured for (a HomeKit leg that is an ffmpeg
|
|
279
|
+
* copy of this restream).
|
|
280
|
+
*
|
|
281
|
+
* The two coincided only because every consumer that re-clocked also happened
|
|
282
|
+
* to be video-only. A sentry is the counter-example: it re-clocks exactly like
|
|
283
|
+
* the `/muted` legs — it is the same ffmpeg-copy shape — and it now carries
|
|
284
|
+
* audio. So the join rule asks THIS, not the mute, and every population keeps
|
|
285
|
+
* its measured behaviour byte-for-byte: `/muted` yes, `/sentry` yes, detection
|
|
286
|
+
* no (it drains the burst by construction — D193), recording and plain no.
|
|
287
|
+
*/
|
|
288
|
+
function isLiveEdgeOnlyDial(dial) {
|
|
289
|
+
if (dial.declaration.intent === "detection") return false;
|
|
290
|
+
if (dial.declaration.intent === "sentry") return true;
|
|
291
|
+
return dial.muted;
|
|
292
|
+
}
|
|
261
293
|
/**
|
|
262
294
|
* The amount a dial URL asks for, read back off the URL itself.
|
|
263
295
|
*
|
|
@@ -364,6 +396,12 @@ Object.defineProperty(exports, "capPreRollRequestMs", {
|
|
|
364
396
|
return capPreRollRequestMs;
|
|
365
397
|
}
|
|
366
398
|
});
|
|
399
|
+
Object.defineProperty(exports, "isLiveEdgeOnlyDial", {
|
|
400
|
+
enumerable: true,
|
|
401
|
+
get: function() {
|
|
402
|
+
return isLiveEdgeOnlyDial;
|
|
403
|
+
}
|
|
404
|
+
});
|
|
367
405
|
Object.defineProperty(exports, "parseRestreamPath", {
|
|
368
406
|
enumerable: true,
|
|
369
407
|
get: function() {
|
|
@@ -388,6 +426,12 @@ Object.defineProperty(exports, "resolvePrebuffer", {
|
|
|
388
426
|
return resolvePrebuffer;
|
|
389
427
|
}
|
|
390
428
|
});
|
|
429
|
+
Object.defineProperty(exports, "sdpVariantFor", {
|
|
430
|
+
enumerable: true,
|
|
431
|
+
get: function() {
|
|
432
|
+
return sdpVariantFor;
|
|
433
|
+
}
|
|
434
|
+
});
|
|
391
435
|
Object.defineProperty(exports, "withDetectionIntent", {
|
|
392
436
|
enumerable: true,
|
|
393
437
|
get: function() {
|
|
@@ -5,7 +5,7 @@ Object.defineProperties(exports, {
|
|
|
5
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
6
|
const require_dist = require("../dist-CsJJPDQW.js");
|
|
7
7
|
const require_node = require("../node-1vbEWzPZ.js");
|
|
8
|
-
const require_restream_intent = require("../restream-intent-
|
|
8
|
+
const require_restream_intent = require("../restream-intent-iKGCB9IR.js");
|
|
9
9
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
10
10
|
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
11
11
|
const require_segment_demux_js = require("../segment-demux-js-OQT6wqXH.js");
|
|
@@ -2550,6 +2550,218 @@ function deviceIdFromBrokerId$1(brokerId) {
|
|
|
2550
2550
|
const parsed = Number(brokerId.split("/")[0]);
|
|
2551
2551
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2552
2552
|
}
|
|
2553
|
+
//#endregion
|
|
2554
|
+
//#region src/stream-broker/rtsp/audio/g711-silence.ts
|
|
2555
|
+
var SILENCE_BYTE = {
|
|
2556
|
+
/** µ-law digital zero. */
|
|
2557
|
+
PCMU: 255,
|
|
2558
|
+
/** A-law digital zero. */
|
|
2559
|
+
PCMA: 213
|
|
2560
|
+
};
|
|
2561
|
+
var STATIC_PAYLOAD_TYPE = {
|
|
2562
|
+
PCMU: 0,
|
|
2563
|
+
PCMA: 8
|
|
2564
|
+
};
|
|
2565
|
+
/** G.711 is 8 kHz mono by definition (RFC 3551). */
|
|
2566
|
+
var G711_CLOCK_RATE_HZ = 8e3;
|
|
2567
|
+
var G711_CHANNELS = 1;
|
|
2568
|
+
/**
|
|
2569
|
+
* The track for a codec name as the camera's SDP spelled it, or `null`.
|
|
2570
|
+
*
|
|
2571
|
+
* `null` is a REFUSAL with a reason the caller must log, never a fallback to
|
|
2572
|
+
* PCMU: a sentry filled with the wrong codec's bytes is audible garbage on
|
|
2573
|
+
* every consumer, which is strictly worse than the silence of no track at all.
|
|
2574
|
+
*/
|
|
2575
|
+
function g711TrackFor(codec) {
|
|
2576
|
+
if (codec === void 0) return null;
|
|
2577
|
+
const upper = codec.toUpperCase();
|
|
2578
|
+
if (upper !== "PCMU" && upper !== "PCMA") return null;
|
|
2579
|
+
return {
|
|
2580
|
+
codec: upper,
|
|
2581
|
+
payloadType: STATIC_PAYLOAD_TYPE[upper],
|
|
2582
|
+
clockRateHz: G711_CLOCK_RATE_HZ,
|
|
2583
|
+
channels: G711_CHANNELS
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* The `m=audio` block grafted onto the sentry's video-only SDP.
|
|
2588
|
+
*
|
|
2589
|
+
* Built rather than extracted from the camera's SDP on purpose. A static
|
|
2590
|
+
* payload type makes the description fully determined by the codec, so the
|
|
2591
|
+
* block is correct even when no camera SDP exists — which is the state this
|
|
2592
|
+
* whole path is for. `extractAudioMediaBlock` cannot serve here: it remaps a
|
|
2593
|
+
* dynamic payload type to 97 for the ffmpeg egress graft, and a remap is
|
|
2594
|
+
* exactly what must not happen to a track the camera will later fill itself.
|
|
2595
|
+
*/
|
|
2596
|
+
function g711AudioMediaBlock(track, trackId) {
|
|
2597
|
+
return [
|
|
2598
|
+
`m=audio 0 RTP/AVP ${track.payloadType}`,
|
|
2599
|
+
"c=IN IP4 0.0.0.0",
|
|
2600
|
+
`a=rtpmap:${track.payloadType} ${track.codec}/${track.clockRateHz}`,
|
|
2601
|
+
`a=control:trackID=${trackId}`
|
|
2602
|
+
].join("\r\n");
|
|
2603
|
+
}
|
|
2604
|
+
/**
|
|
2605
|
+
* One complete RTP packet of silence: 12-byte header plus one frame of the
|
|
2606
|
+
* codec's zero byte. Pure — the caller owns the sequence, the clock and the
|
|
2607
|
+
* decision to send.
|
|
2608
|
+
*/
|
|
2609
|
+
function buildG711SilenceRtp(input) {
|
|
2610
|
+
const packet = Buffer.alloc(172, SILENCE_BYTE[input.track.codec]);
|
|
2611
|
+
packet[0] = 128;
|
|
2612
|
+
packet[1] = (input.marker ? 128 : 0) | input.track.payloadType & 127;
|
|
2613
|
+
packet.writeUInt16BE(input.sequenceNumber & 65535, 2);
|
|
2614
|
+
packet.writeUInt32BE(input.rtpTimestamp >>> 0, 4);
|
|
2615
|
+
packet.writeUInt32BE(input.ssrc >>> 0, 8);
|
|
2616
|
+
return packet;
|
|
2617
|
+
}
|
|
2618
|
+
//#endregion
|
|
2619
|
+
//#region src/stream-broker/rtsp/audio/sentry-audio-silence.ts
|
|
2620
|
+
/**
|
|
2621
|
+
* How long the camera must be quiet before the filler takes the track back.
|
|
2622
|
+
*
|
|
2623
|
+
* Longer than any plausible inter-packet gap on a live G.711 stream (20 ms
|
|
2624
|
+
* nominal, 50 pps) by two orders of magnitude, so a momentary stutter in real
|
|
2625
|
+
* audio can never be papered over with generated frames; short enough that a
|
|
2626
|
+
* consumer's decoder does not see a hole when the camera goes back to sleep.
|
|
2627
|
+
*/
|
|
2628
|
+
var DEFAULT_RESUME_GAP_MS = 1e3;
|
|
2629
|
+
var SentryAudioSilence = class {
|
|
2630
|
+
options;
|
|
2631
|
+
frameMs;
|
|
2632
|
+
resumeGapMs;
|
|
2633
|
+
tags;
|
|
2634
|
+
ssrc;
|
|
2635
|
+
timer;
|
|
2636
|
+
resumeTimer;
|
|
2637
|
+
sequenceNumber;
|
|
2638
|
+
rtpTimestamp = 0;
|
|
2639
|
+
markNext = true;
|
|
2640
|
+
emitted = 0;
|
|
2641
|
+
/** Frames that reached at least one session. See {@link stopRun}. */
|
|
2642
|
+
delivered = 0;
|
|
2643
|
+
lastRealAudioAt = 0;
|
|
2644
|
+
wanted = false;
|
|
2645
|
+
constructor(options) {
|
|
2646
|
+
this.options = options;
|
|
2647
|
+
this.frameMs = options.frameMs ?? 20;
|
|
2648
|
+
this.resumeGapMs = options.resumeGapMs ?? DEFAULT_RESUME_GAP_MS;
|
|
2649
|
+
this.tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
|
|
2650
|
+
this.ssrc = Math.random() * 4294967295 >>> 0;
|
|
2651
|
+
this.sequenceNumber = Math.random() * 65535 & 65535;
|
|
2652
|
+
}
|
|
2653
|
+
/** The audio shape this filler was built for. */
|
|
2654
|
+
get track() {
|
|
2655
|
+
return this.options.track;
|
|
2656
|
+
}
|
|
2657
|
+
/** True while packets are actually being produced. */
|
|
2658
|
+
get running() {
|
|
2659
|
+
return this.timer !== void 0;
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* Somebody wants the track filled. Idempotent — reconciliation calls this on
|
|
2663
|
+
* every session edge, and a second call while a run is in flight must not
|
|
2664
|
+
* restart the clock or re-log.
|
|
2665
|
+
*/
|
|
2666
|
+
want() {
|
|
2667
|
+
this.wanted = true;
|
|
2668
|
+
this.startRun();
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Nobody wants the track filled any more (the last sentry left, the operator
|
|
2672
|
+
* muted the device, the broker is going away). Idempotent.
|
|
2673
|
+
*/
|
|
2674
|
+
release(reason) {
|
|
2675
|
+
this.wanted = false;
|
|
2676
|
+
this.clearResume();
|
|
2677
|
+
this.stopRun(reason);
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* The camera's own audio just arrived.
|
|
2681
|
+
*
|
|
2682
|
+
* Synchronous, and the first thing `pushAudioRtpPassthrough` does: the timer
|
|
2683
|
+
* is gone before this packet is written to any session, so no generated frame
|
|
2684
|
+
* can follow it. Called on EVERY real packet, not only the first — the rolling
|
|
2685
|
+
* gap is what lets the filler come back when the camera sleeps again, without
|
|
2686
|
+
* a second signal anybody has to remember to send.
|
|
2687
|
+
*/
|
|
2688
|
+
noteRealAudio(nowMs) {
|
|
2689
|
+
this.lastRealAudioAt = nowMs;
|
|
2690
|
+
this.stopRun("real audio");
|
|
2691
|
+
this.armResume();
|
|
2692
|
+
}
|
|
2693
|
+
startRun() {
|
|
2694
|
+
if (this.timer !== void 0) return;
|
|
2695
|
+
if (!this.wanted) return;
|
|
2696
|
+
if (Date.now() - this.lastRealAudioAt < this.resumeGapMs) {
|
|
2697
|
+
this.armResume();
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
this.rtpTimestamp = Math.floor(Date.now() * this.options.track.clockRateHz / 1e3) >>> 0;
|
|
2701
|
+
this.markNext = true;
|
|
2702
|
+
this.emitted = 0;
|
|
2703
|
+
this.delivered = 0;
|
|
2704
|
+
this.timer = setInterval(() => this.tick(), this.frameMs);
|
|
2705
|
+
this.options.logger?.info("sentry audio: filling the track with silence", {
|
|
2706
|
+
...this.tags,
|
|
2707
|
+
meta: {
|
|
2708
|
+
codec: this.options.track.codec,
|
|
2709
|
+
payloadType: this.options.track.payloadType,
|
|
2710
|
+
frameMs: this.frameMs,
|
|
2711
|
+
clockRateHz: this.options.track.clockRateHz
|
|
2712
|
+
}
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
stopRun(reason) {
|
|
2716
|
+
if (this.timer === void 0) return;
|
|
2717
|
+
clearInterval(this.timer);
|
|
2718
|
+
this.timer = void 0;
|
|
2719
|
+
this.options.logger?.info("sentry audio: stopped filling the track", {
|
|
2720
|
+
...this.tags,
|
|
2721
|
+
meta: {
|
|
2722
|
+
reason,
|
|
2723
|
+
framesEmitted: this.emitted,
|
|
2724
|
+
framesDelivered: this.delivered
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Re-check once the gap has elapsed.
|
|
2730
|
+
*
|
|
2731
|
+
* A single pending timeout rather than a free-running ticker that decides per
|
|
2732
|
+
* frame: while the camera is awake this costs one timer per second instead of
|
|
2733
|
+
* fifty, and while it is asleep it costs nothing at all — the run is the
|
|
2734
|
+
* ticker.
|
|
2735
|
+
*/
|
|
2736
|
+
armResume() {
|
|
2737
|
+
if (!this.wanted) return;
|
|
2738
|
+
if (this.resumeTimer !== void 0) return;
|
|
2739
|
+
this.resumeTimer = setTimeout(() => {
|
|
2740
|
+
this.resumeTimer = void 0;
|
|
2741
|
+
this.startRun();
|
|
2742
|
+
}, this.resumeGapMs);
|
|
2743
|
+
this.resumeTimer.unref?.();
|
|
2744
|
+
}
|
|
2745
|
+
clearResume() {
|
|
2746
|
+
if (this.resumeTimer === void 0) return;
|
|
2747
|
+
clearTimeout(this.resumeTimer);
|
|
2748
|
+
this.resumeTimer = void 0;
|
|
2749
|
+
}
|
|
2750
|
+
tick() {
|
|
2751
|
+
const rtp = buildG711SilenceRtp({
|
|
2752
|
+
track: this.options.track,
|
|
2753
|
+
ssrc: this.ssrc,
|
|
2754
|
+
sequenceNumber: this.sequenceNumber,
|
|
2755
|
+
rtpTimestamp: this.rtpTimestamp,
|
|
2756
|
+
marker: this.markNext
|
|
2757
|
+
});
|
|
2758
|
+
this.sequenceNumber = this.sequenceNumber + 1 & 65535;
|
|
2759
|
+
this.rtpTimestamp = this.rtpTimestamp + 160 >>> 0;
|
|
2760
|
+
this.markNext = false;
|
|
2761
|
+
this.emitted += 1;
|
|
2762
|
+
if (this.options.emit(rtp) > 0) this.delivered += 1;
|
|
2763
|
+
}
|
|
2764
|
+
};
|
|
2553
2765
|
/**
|
|
2554
2766
|
* Extract the `m=audio` media block from an ffmpeg-generated SDP, normalised
|
|
2555
2767
|
* for grafting (port 0, non-colliding payload type, own `a=control`, explicit
|
|
@@ -2597,7 +2809,7 @@ function appendAudioMediaBlock(videoSdp, audioBlock) {
|
|
|
2597
2809
|
* the result onto its counters and its `continue`.
|
|
2598
2810
|
*/
|
|
2599
2811
|
function admitJoinBurst(input) {
|
|
2600
|
-
if (input.
|
|
2812
|
+
if (input.liveEdgeOnly) return !input.fromRing || input.spanMs > input.liveEdgeMaxSpanMs ? "live-edge" : "none";
|
|
2601
2813
|
if (!input.detectionIntent && input.spanMs > input.bound.maxSpanMs) return "span";
|
|
2602
2814
|
if (input.packets > input.bound.maxPackets) return "packets";
|
|
2603
2815
|
return "none";
|
|
@@ -2799,6 +3011,14 @@ var LIVE_EDGE_MAX_SPAN_MS = 500;
|
|
|
2799
3011
|
*/
|
|
2800
3012
|
var PRIME_MAX_SPAN_MS = 2e3;
|
|
2801
3013
|
/**
|
|
3014
|
+
* The `a=control:trackID=` the sentry's grafted audio block is given.
|
|
3015
|
+
*
|
|
3016
|
+
* The same id the transcode egress grafts its own audio onto
|
|
3017
|
+
* (`extractAudioMediaBlock`'s default) and the same one `sendAudioRtp` writes
|
|
3018
|
+
* to (`tracks[1]`), because the video track is always trackID=0.
|
|
3019
|
+
*/
|
|
3020
|
+
var SENTRY_AUDIO_TRACK_ID = 1;
|
|
3021
|
+
/**
|
|
2802
3022
|
* Defensive packet ceiling on a join burst, independent of {@link
|
|
2803
3023
|
* PRIME_MAX_SPAN_MS}. The ring is trimmed to one GOP by RTP timestamp, so a
|
|
2804
3024
|
* burst this large means the timestamps stopped describing the stream (wrap,
|
|
@@ -2891,6 +3111,27 @@ var RtspRestreamer = class {
|
|
|
2891
3111
|
* a broker going idle overnight would wake up distributing audio again.
|
|
2892
3112
|
*/
|
|
2893
3113
|
deviceAudioMuted = false;
|
|
3114
|
+
/**
|
|
3115
|
+
* The camera's audio shape, remembered so a SENTRY can be handed a track to
|
|
3116
|
+
* fill while the camera is asleep.
|
|
3117
|
+
*
|
|
3118
|
+
* NOT cleared by {@link resetStreamState}, for exactly the reason
|
|
3119
|
+
* {@link deviceAudioMuted} is not: that method resets what belongs to one
|
|
3120
|
+
* CONNECTION, and this describes the CAMERA. A sleeping camera has no SDP to
|
|
3121
|
+
* read — it is the whole premise of the sentry — so if this did not survive
|
|
3122
|
+
* the suspend there would never be an audio track to announce at the moment
|
|
3123
|
+
* one is needed.
|
|
3124
|
+
*
|
|
3125
|
+
* `null` means nothing has ever described this camera's audio to us (a cold
|
|
3126
|
+
* hub whose battery camera has not been awake since boot), or its codec is
|
|
3127
|
+
* one whose silence cannot be generated exactly. Both are logged; neither is
|
|
3128
|
+
* guessed at.
|
|
3129
|
+
*/
|
|
3130
|
+
sentryAudioTrack = null;
|
|
3131
|
+
/** Fills a sentry's audio track while the camera sleeps. Built on demand. */
|
|
3132
|
+
sentryAudioSilence = null;
|
|
3133
|
+
/** One-shot guard for "a sentry attached and there is no track to fill". */
|
|
3134
|
+
sentryAudioGapLogged = false;
|
|
2894
3135
|
/** Cached last keyframe NALs (SPS/PPS + IDR) for late-joining clients. */
|
|
2895
3136
|
lastKeyframeNals = [];
|
|
2896
3137
|
lastKeyframeNalBytes = 0;
|
|
@@ -3099,6 +3340,156 @@ var RtspRestreamer = class {
|
|
|
3099
3340
|
return this.mutedSdp ?? this.sdp;
|
|
3100
3341
|
}
|
|
3101
3342
|
/**
|
|
3343
|
+
* The SDP a SENTRY dial is answered with: the picture it is being served,
|
|
3344
|
+
* plus the audio track that picture will be accompanied by.
|
|
3345
|
+
*
|
|
3346
|
+
* Three cases, in this order and no other:
|
|
3347
|
+
*
|
|
3348
|
+
* 1. The operator muted this camera broker-side (D83). That mute IS a device
|
|
3349
|
+
* policy and outranks everything here — the sentry is answered video-only,
|
|
3350
|
+
* exactly as every other consumer is.
|
|
3351
|
+
* 2. The current connection already describes audio (the camera is up and we
|
|
3352
|
+
* are serving its own SDP). It is served verbatim: grafting on top would
|
|
3353
|
+
* announce the track twice, and the camera's own description is by
|
|
3354
|
+
* definition the one its RTP will match.
|
|
3355
|
+
* 3. Otherwise — the sleeping case, where the SDP is built from the
|
|
3356
|
+
* placeholder frame and has no audio at all — the retained shape is
|
|
3357
|
+
* grafted on as trackID=1.
|
|
3358
|
+
*
|
|
3359
|
+
* Without a retained shape this degrades to the video-only SDP, which is what
|
|
3360
|
+
* the whole path used to serve unconditionally. That is a REFUSAL, reported
|
|
3361
|
+
* by {@link reconcileSentryAudio}: a sentry with no audio is a sentry that
|
|
3362
|
+
* will never have any, and the operator has to be able to see it rather than
|
|
3363
|
+
* infer it from a silent stream.
|
|
3364
|
+
*/
|
|
3365
|
+
getSentrySdp() {
|
|
3366
|
+
if (this.deviceAudioMuted) return this.getMutedSdp();
|
|
3367
|
+
const full = this.sdp;
|
|
3368
|
+
if (full !== null && /^m=audio/m.test(full)) return full;
|
|
3369
|
+
const videoOnly = this.getMutedSdp();
|
|
3370
|
+
const track = this.sentryAudioTrack;
|
|
3371
|
+
if (track === null || videoOnly === null) return videoOnly;
|
|
3372
|
+
return appendAudioMediaBlock(videoOnly, g711AudioMediaBlock(track, SENTRY_AUDIO_TRACK_ID));
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* The SDP for a parsed dial's variant.
|
|
3376
|
+
*
|
|
3377
|
+
* One switch and not three call sites: the listen server reads the SDP twice
|
|
3378
|
+
* (once to wait for it, once to answer with it), and those two reads
|
|
3379
|
+
* disagreeing about which variant a dial gets is a class of bug that costs a
|
|
3380
|
+
* consumer an empty DESCRIBE.
|
|
3381
|
+
*/
|
|
3382
|
+
getSdpFor(variant) {
|
|
3383
|
+
if (variant === "sentry") return this.getSentrySdp();
|
|
3384
|
+
if (variant === "muted") return this.getMutedSdp();
|
|
3385
|
+
return this.getSdp();
|
|
3386
|
+
}
|
|
3387
|
+
/**
|
|
3388
|
+
* Remember (or forget) the camera's audio shape for the sentry.
|
|
3389
|
+
*
|
|
3390
|
+
* Called from the broker's `onAudioTrack`, which is the only place the
|
|
3391
|
+
* camera's own SDP is parsed. `null` forgets it — a camera whose audio was
|
|
3392
|
+
* turned off, or whose codec this system cannot fill exactly.
|
|
3393
|
+
*/
|
|
3394
|
+
retainSentryAudioTrack(track) {
|
|
3395
|
+
const prior = this.sentryAudioTrack;
|
|
3396
|
+
if (prior?.codec === track?.codec && prior?.payloadType === track?.payloadType) return;
|
|
3397
|
+
this.sentryAudioTrack = track;
|
|
3398
|
+
this.sentryAudioSilence?.release("audio shape changed");
|
|
3399
|
+
this.sentryAudioSilence = null;
|
|
3400
|
+
this.sentryAudioGapLogged = false;
|
|
3401
|
+
this.logger?.info("sentry audio: track shape retained", {
|
|
3402
|
+
tags: { deviceId: this.deviceId },
|
|
3403
|
+
meta: {
|
|
3404
|
+
streamName: this.streamName,
|
|
3405
|
+
codec: track?.codec ?? null
|
|
3406
|
+
}
|
|
3407
|
+
});
|
|
3408
|
+
this.reconcileSentryAudio();
|
|
3409
|
+
}
|
|
3410
|
+
/** The retained audio shape, or `null`. Survives a suspend by design. */
|
|
3411
|
+
getSentryAudioTrack() {
|
|
3412
|
+
return this.sentryAudioTrack;
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* Whether generated silence is being produced for this stream right now.
|
|
3416
|
+
*
|
|
3417
|
+
* Exposed because "is the filler still running" is not answerable from the
|
|
3418
|
+
* wire: a run whose last sentry has left writes to nobody, so a socket that
|
|
3419
|
+
* sees nothing looks exactly like a timer that was released and exactly like
|
|
3420
|
+
* one that was leaked. That ambiguity survived a deliberate sabotage of
|
|
3421
|
+
* {@link removeSession}'s reconcile with the whole suite green.
|
|
3422
|
+
*/
|
|
3423
|
+
isSentryAudioFilling() {
|
|
3424
|
+
return this.sentryAudioSilence?.running === true;
|
|
3425
|
+
}
|
|
3426
|
+
/**
|
|
3427
|
+
* Start or stop filling the sentry audio track to match who is attached.
|
|
3428
|
+
*
|
|
3429
|
+
* Called on every edge that can change the answer — a session arriving or
|
|
3430
|
+
* leaving, the operator's mute, a new audio shape — and never on a per-packet
|
|
3431
|
+
* path. Idempotent in both directions.
|
|
3432
|
+
*/
|
|
3433
|
+
reconcileSentryAudio() {
|
|
3434
|
+
if (!(this.getPrestreamSessionCount() > 0 && !this.deviceAudioMuted)) {
|
|
3435
|
+
this.sentryAudioSilence?.release("no sentry session");
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
const track = this.sentryAudioTrack;
|
|
3439
|
+
if (track === null) {
|
|
3440
|
+
if (!this.sentryAudioGapLogged) {
|
|
3441
|
+
this.sentryAudioGapLogged = true;
|
|
3442
|
+
this.logger?.info("sentry audio: no track to fill — this dial gets no audio", {
|
|
3443
|
+
tags: { deviceId: this.deviceId },
|
|
3444
|
+
meta: {
|
|
3445
|
+
streamName: this.streamName,
|
|
3446
|
+
reason: "no retained g711 audio shape"
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
}
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
if (this.sentryAudioSilence === null) this.sentryAudioSilence = new SentryAudioSilence({
|
|
3453
|
+
track,
|
|
3454
|
+
emit: (rtp) => this.sendSilenceToSentrySessions(rtp),
|
|
3455
|
+
...this.logger ? { logger: this.logger } : {},
|
|
3456
|
+
...this.deviceId === void 0 ? {} : { deviceId: this.deviceId }
|
|
3457
|
+
});
|
|
3458
|
+
this.sentryAudioSilence.want();
|
|
3459
|
+
}
|
|
3460
|
+
/**
|
|
3461
|
+
* Hand one generated packet to every sentry session that can take it.
|
|
3462
|
+
*
|
|
3463
|
+
* Gated on PLAY, on the sentry intent and on the session's own mute — and
|
|
3464
|
+
* deliberately NOT on `pendingKeyframe`, which is the one gate the real audio
|
|
3465
|
+
* path applies and this one must not.
|
|
3466
|
+
*
|
|
3467
|
+
* That gate exists to keep live audio behind a video join REPLAY, so a
|
|
3468
|
+
* session's two tracks are not handed media from two different moments.
|
|
3469
|
+
* Generated silence belongs to no replay and to no GOP. Worse, waiting for
|
|
3470
|
+
* the video would reproduce the defect: the placeholder picture arrives at
|
|
3471
|
+
* 1 fps at best and not at all when the sentry refuses (unknown geometry, no
|
|
3472
|
+
* cached snapshot), so the track a consumer's ffmpeg is waiting on would stay
|
|
3473
|
+
* empty for exactly as long as the picture did — which is the stall the
|
|
3474
|
+
* announced-but-unfilled `m=audio` was feared for in the first place.
|
|
3475
|
+
*
|
|
3476
|
+
* Returns how many took it, so a run that delivers to nobody is visible.
|
|
3477
|
+
*/
|
|
3478
|
+
sendSilenceToSentrySessions(rtp) {
|
|
3479
|
+
const packet = {
|
|
3480
|
+
data: rtp,
|
|
3481
|
+
marker: (rtp[1] & 128) !== 0
|
|
3482
|
+
};
|
|
3483
|
+
let served = 0;
|
|
3484
|
+
for (const session of this.sessions.values()) {
|
|
3485
|
+
if (!session.isPrestreamIntent()) continue;
|
|
3486
|
+
if (session.isMuted()) continue;
|
|
3487
|
+
if (!session.isPlaying()) continue;
|
|
3488
|
+
if (session.sendAudioRtp(packet) === "written") served += 1;
|
|
3489
|
+
}
|
|
3490
|
+
return served;
|
|
3491
|
+
}
|
|
3492
|
+
/**
|
|
3102
3493
|
* Apply the broker-side audio mute. Returns how many audio-bearing pull
|
|
3103
3494
|
* sessions were closed so they re-`DESCRIBE` against the new SDP.
|
|
3104
3495
|
*
|
|
@@ -3110,6 +3501,7 @@ var RtspRestreamer = class {
|
|
|
3110
3501
|
setAudioMuted(muted) {
|
|
3111
3502
|
if (this.deviceAudioMuted === muted) return 0;
|
|
3112
3503
|
this.deviceAudioMuted = muted;
|
|
3504
|
+
this.reconcileSentryAudio();
|
|
3113
3505
|
const audioBearing = [...this.sessions.values()].filter((s) => !s.isMuted());
|
|
3114
3506
|
for (const session of audioBearing) this.killSession(session.getSessionId());
|
|
3115
3507
|
if (audioBearing.length > 0) this.logger?.info("audio mute changed — closing audio-bearing RTSP pulls so they re-DESCRIBE", {
|
|
@@ -3161,6 +3553,7 @@ var RtspRestreamer = class {
|
|
|
3161
3553
|
total: this.sessions.size
|
|
3162
3554
|
} });
|
|
3163
3555
|
this._onSessionCountChanged?.();
|
|
3556
|
+
this.reconcileSentryAudio();
|
|
3164
3557
|
if (this._rtpPassthroughMode) setTimeout(() => this.servePendingSessionsRtp(), 0);
|
|
3165
3558
|
else if (this.lastKeyframeNals.length > 0 && this.packetizer) setTimeout(() => this.servePendingSessions(), 0);
|
|
3166
3559
|
}
|
|
@@ -3336,7 +3729,7 @@ var RtspRestreamer = class {
|
|
|
3336
3729
|
}
|
|
3337
3730
|
}
|
|
3338
3731
|
const withhold = admitJoinBurst({
|
|
3339
|
-
|
|
3732
|
+
liveEdgeOnly: session.isLiveEdgeOnly(),
|
|
3340
3733
|
detectionIntent,
|
|
3341
3734
|
fromRing,
|
|
3342
3735
|
spanMs,
|
|
@@ -3599,6 +3992,7 @@ var RtspRestreamer = class {
|
|
|
3599
3992
|
*/
|
|
3600
3993
|
pushAudioRtpPassthrough(rtpData) {
|
|
3601
3994
|
if (this.deviceAudioMuted) return;
|
|
3995
|
+
this.sentryAudioSilence?.noteRealAudio(Date.now());
|
|
3602
3996
|
if (this.sessions.size === 0) return;
|
|
3603
3997
|
const rtpPacket = {
|
|
3604
3998
|
data: rtpData,
|
|
@@ -3619,6 +4013,7 @@ var RtspRestreamer = class {
|
|
|
3619
4013
|
total: this.sessions.size
|
|
3620
4014
|
} });
|
|
3621
4015
|
this._onSessionCountChanged?.();
|
|
4016
|
+
this.reconcileSentryAudio();
|
|
3622
4017
|
}
|
|
3623
4018
|
/**
|
|
3624
4019
|
* Force-close + remove a session by id. Used by the admin `killClient`
|
|
@@ -3647,6 +4042,7 @@ var RtspRestreamer = class {
|
|
|
3647
4042
|
}
|
|
3648
4043
|
destroy() {
|
|
3649
4044
|
this._onSessionCountChanged = null;
|
|
4045
|
+
this.sentryAudioSilence?.release("restreamer destroyed");
|
|
3650
4046
|
this.abortAllReplays();
|
|
3651
4047
|
this.deframer?.flush();
|
|
3652
4048
|
for (const session of this.sessions.values()) session.close();
|
|
@@ -3816,9 +4212,11 @@ var RtspSession = class {
|
|
|
3816
4212
|
* {@link NO_DECLARATION} when it declared nothing — which is every consumer
|
|
3817
4213
|
* except the per-session detection decode and the recorder's writer.
|
|
3818
4214
|
*
|
|
3819
|
-
* Kept separate from {@link muted} deliberately:
|
|
3820
|
-
*
|
|
3821
|
-
*
|
|
4215
|
+
* Kept separate from {@link muted} deliberately: the two say different things
|
|
4216
|
+
* and, since D525, they no longer coincide — a `/sentry` dial declares an
|
|
4217
|
+
* intent and is NOT muted, while the join rule that used to read `isMuted()`
|
|
4218
|
+
* now reads {@link isLiveEdgeOnly} and keeps every older population's
|
|
4219
|
+
* behaviour byte-for-byte.
|
|
3822
4220
|
*
|
|
3823
4221
|
* One field and not two, because the purpose and the amount are one
|
|
3824
4222
|
* statement (D382): a session that names a purpose without naming an amount
|
|
@@ -3880,6 +4278,20 @@ var RtspSession = class {
|
|
|
3880
4278
|
return this.muted;
|
|
3881
4279
|
}
|
|
3882
4280
|
/**
|
|
4281
|
+
* Whether this session is a RE-CLOCKING leg that may only join at the live
|
|
4282
|
+
* edge — the second question `isMuted()` used to answer.
|
|
4283
|
+
*
|
|
4284
|
+
* Derived from the one authority (`isLiveEdgeOnlyDial`) rather than stored,
|
|
4285
|
+
* so the rule cannot drift between the parser and the session. Unchanged for
|
|
4286
|
+
* every population that existed before the sentry gained audio.
|
|
4287
|
+
*/
|
|
4288
|
+
isLiveEdgeOnly() {
|
|
4289
|
+
return require_restream_intent.isLiveEdgeOnlyDial({
|
|
4290
|
+
muted: this.muted,
|
|
4291
|
+
declaration: this.declaration
|
|
4292
|
+
});
|
|
4293
|
+
}
|
|
4294
|
+
/**
|
|
3883
4295
|
* The `User-Agent` this client put on its first request, or `null`.
|
|
3884
4296
|
*
|
|
3885
4297
|
* The ONLY identity that crosses from a consumer process into this session.
|
|
@@ -4329,7 +4741,9 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4329
4741
|
async dispatch(socket, requestBuffer) {
|
|
4330
4742
|
const uriMatch = (requestBuffer.split("\r\n")[0] ?? "").match(/\s(rtsp:\/\/[^\s]+)\s/);
|
|
4331
4743
|
const pathMatch = (uriMatch ? uriMatch[1] : "").match(/rtsp:\/\/[^/]+(\/.*?)(?:\s|$)/);
|
|
4332
|
-
const
|
|
4744
|
+
const parsed = require_restream_intent.parseRestreamPath(pathMatch ? pathMatch[1].replace(/^\//, "").replace(/\/trackID=\d+$/, "") : "");
|
|
4745
|
+
const { lookupPath, muted: isMuted, declaration } = parsed;
|
|
4746
|
+
const sdpVariant = require_restream_intent.sdpVariantFor(parsed);
|
|
4333
4747
|
const restreamer = this.restreamers.get(lookupPath);
|
|
4334
4748
|
if (!restreamer) {
|
|
4335
4749
|
socket.write(`RTSP/1.0 404 Not Found\r\nCSeq: 1\r\n\r\n`);
|
|
@@ -4356,8 +4770,8 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4356
4770
|
};
|
|
4357
4771
|
socket.once("close", releaseOnce);
|
|
4358
4772
|
socket.once("error", releaseOnce);
|
|
4359
|
-
await this.waitForSdp(restreamer,
|
|
4360
|
-
const session = new RtspSession(socket,
|
|
4773
|
+
await this.waitForSdp(restreamer, sdpVariant, 3e3);
|
|
4774
|
+
const session = new RtspSession(socket, restreamer.getSdpFor(sdpVariant) ?? restreamer.getSdp() ?? "", () => {
|
|
4361
4775
|
restreamer.removeSession(session.getSessionId());
|
|
4362
4776
|
releaseOnce();
|
|
4363
4777
|
}, isMuted, declaration);
|
|
@@ -4372,10 +4786,10 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4372
4786
|
* a cold broker that's typically ~200-1000 ms. Resolves `true` if the
|
|
4373
4787
|
* SDP becomes non-empty within the budget, `false` on timeout.
|
|
4374
4788
|
*/
|
|
4375
|
-
async waitForSdp(restreamer,
|
|
4789
|
+
async waitForSdp(restreamer, variant, timeoutMs) {
|
|
4376
4790
|
const start = Date.now();
|
|
4377
4791
|
const have = () => {
|
|
4378
|
-
const s =
|
|
4792
|
+
const s = restreamer.getSdpFor(variant) ?? restreamer.getSdp();
|
|
4379
4793
|
return !!s && s.length > 0;
|
|
4380
4794
|
};
|
|
4381
4795
|
if (have()) return true;
|
|
@@ -15022,6 +15436,12 @@ var StreamBroker = class StreamBroker {
|
|
|
15022
15436
|
supportedNative
|
|
15023
15437
|
}
|
|
15024
15438
|
});
|
|
15439
|
+
const sentryTrack = g711TrackFor(codecUpper);
|
|
15440
|
+
this.rtspRestreamer.retainSentryAudioTrack(sentryTrack);
|
|
15441
|
+
if (sentryTrack === null) this.logger?.info("sentry audio: codec cannot be filled — a sentry gets no audio", {
|
|
15442
|
+
tags: { deviceId: this.numericDeviceId },
|
|
15443
|
+
meta: { codec: codecUpper }
|
|
15444
|
+
});
|
|
15025
15445
|
let supported = supportedNative;
|
|
15026
15446
|
let effectiveSampleRate = audioTrack.clockRate;
|
|
15027
15447
|
let effectiveChannels = audioTrack.channels;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
|
|
2
2
|
import { A as RingBuffer, B as declareLogChannel, Bt as buildFfmpegArgs$1, E as RATE_CONTROL_TIGHT, Et as resolveEgressDecodeHwAccel, Gt as CAM_PROFILE_ORDER, H as deriveBatteryPresence, Ht as isSoftwareDecode, I as batteryCapability, It as webrtcSessionCapability, K as egressTranscodeSharingKey, Kt as DeviceFeature, L as cameraStreamsCapability, Lt as errMsg, M as addonWidgetsSourceCapability, Nt as streamBrokerCapability, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as AUDIO_PRESETS, T as RATE_CONTROL_RELAXED, Ut as BOOT_RECOVERY_BACKOFF_MS, Vt as invocationFromEncodeProfile, Wt as BaseAddon, Yt as createEvent, _n as record, an as parseProfileBrokerId, at as logChannelsCapability, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, ln as array, mn as number, nn as makeSourceBrokerId, pn as literal, q as egressTransportFromRequest, qt as DeviceType, rn as nodePin, st as maskUrlCredentials, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, xt as rankStreams, yn as union, z as createLogChannelsProvider } from "../dist-D3w2fwD4.mjs";
|
|
3
3
|
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-IHGNrWH9.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-CW79dvFV.mjs";
|
|
5
5
|
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
6
6
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
7
7
|
import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-BZAiu_2h.mjs";
|
|
@@ -2546,6 +2546,218 @@ function deviceIdFromBrokerId$1(brokerId) {
|
|
|
2546
2546
|
const parsed = Number(brokerId.split("/")[0]);
|
|
2547
2547
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2548
2548
|
}
|
|
2549
|
+
//#endregion
|
|
2550
|
+
//#region src/stream-broker/rtsp/audio/g711-silence.ts
|
|
2551
|
+
var SILENCE_BYTE = {
|
|
2552
|
+
/** µ-law digital zero. */
|
|
2553
|
+
PCMU: 255,
|
|
2554
|
+
/** A-law digital zero. */
|
|
2555
|
+
PCMA: 213
|
|
2556
|
+
};
|
|
2557
|
+
var STATIC_PAYLOAD_TYPE = {
|
|
2558
|
+
PCMU: 0,
|
|
2559
|
+
PCMA: 8
|
|
2560
|
+
};
|
|
2561
|
+
/** G.711 is 8 kHz mono by definition (RFC 3551). */
|
|
2562
|
+
var G711_CLOCK_RATE_HZ = 8e3;
|
|
2563
|
+
var G711_CHANNELS = 1;
|
|
2564
|
+
/**
|
|
2565
|
+
* The track for a codec name as the camera's SDP spelled it, or `null`.
|
|
2566
|
+
*
|
|
2567
|
+
* `null` is a REFUSAL with a reason the caller must log, never a fallback to
|
|
2568
|
+
* PCMU: a sentry filled with the wrong codec's bytes is audible garbage on
|
|
2569
|
+
* every consumer, which is strictly worse than the silence of no track at all.
|
|
2570
|
+
*/
|
|
2571
|
+
function g711TrackFor(codec) {
|
|
2572
|
+
if (codec === void 0) return null;
|
|
2573
|
+
const upper = codec.toUpperCase();
|
|
2574
|
+
if (upper !== "PCMU" && upper !== "PCMA") return null;
|
|
2575
|
+
return {
|
|
2576
|
+
codec: upper,
|
|
2577
|
+
payloadType: STATIC_PAYLOAD_TYPE[upper],
|
|
2578
|
+
clockRateHz: G711_CLOCK_RATE_HZ,
|
|
2579
|
+
channels: G711_CHANNELS
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* The `m=audio` block grafted onto the sentry's video-only SDP.
|
|
2584
|
+
*
|
|
2585
|
+
* Built rather than extracted from the camera's SDP on purpose. A static
|
|
2586
|
+
* payload type makes the description fully determined by the codec, so the
|
|
2587
|
+
* block is correct even when no camera SDP exists — which is the state this
|
|
2588
|
+
* whole path is for. `extractAudioMediaBlock` cannot serve here: it remaps a
|
|
2589
|
+
* dynamic payload type to 97 for the ffmpeg egress graft, and a remap is
|
|
2590
|
+
* exactly what must not happen to a track the camera will later fill itself.
|
|
2591
|
+
*/
|
|
2592
|
+
function g711AudioMediaBlock(track, trackId) {
|
|
2593
|
+
return [
|
|
2594
|
+
`m=audio 0 RTP/AVP ${track.payloadType}`,
|
|
2595
|
+
"c=IN IP4 0.0.0.0",
|
|
2596
|
+
`a=rtpmap:${track.payloadType} ${track.codec}/${track.clockRateHz}`,
|
|
2597
|
+
`a=control:trackID=${trackId}`
|
|
2598
|
+
].join("\r\n");
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* One complete RTP packet of silence: 12-byte header plus one frame of the
|
|
2602
|
+
* codec's zero byte. Pure — the caller owns the sequence, the clock and the
|
|
2603
|
+
* decision to send.
|
|
2604
|
+
*/
|
|
2605
|
+
function buildG711SilenceRtp(input) {
|
|
2606
|
+
const packet = Buffer.alloc(172, SILENCE_BYTE[input.track.codec]);
|
|
2607
|
+
packet[0] = 128;
|
|
2608
|
+
packet[1] = (input.marker ? 128 : 0) | input.track.payloadType & 127;
|
|
2609
|
+
packet.writeUInt16BE(input.sequenceNumber & 65535, 2);
|
|
2610
|
+
packet.writeUInt32BE(input.rtpTimestamp >>> 0, 4);
|
|
2611
|
+
packet.writeUInt32BE(input.ssrc >>> 0, 8);
|
|
2612
|
+
return packet;
|
|
2613
|
+
}
|
|
2614
|
+
//#endregion
|
|
2615
|
+
//#region src/stream-broker/rtsp/audio/sentry-audio-silence.ts
|
|
2616
|
+
/**
|
|
2617
|
+
* How long the camera must be quiet before the filler takes the track back.
|
|
2618
|
+
*
|
|
2619
|
+
* Longer than any plausible inter-packet gap on a live G.711 stream (20 ms
|
|
2620
|
+
* nominal, 50 pps) by two orders of magnitude, so a momentary stutter in real
|
|
2621
|
+
* audio can never be papered over with generated frames; short enough that a
|
|
2622
|
+
* consumer's decoder does not see a hole when the camera goes back to sleep.
|
|
2623
|
+
*/
|
|
2624
|
+
var DEFAULT_RESUME_GAP_MS = 1e3;
|
|
2625
|
+
var SentryAudioSilence = class {
|
|
2626
|
+
options;
|
|
2627
|
+
frameMs;
|
|
2628
|
+
resumeGapMs;
|
|
2629
|
+
tags;
|
|
2630
|
+
ssrc;
|
|
2631
|
+
timer;
|
|
2632
|
+
resumeTimer;
|
|
2633
|
+
sequenceNumber;
|
|
2634
|
+
rtpTimestamp = 0;
|
|
2635
|
+
markNext = true;
|
|
2636
|
+
emitted = 0;
|
|
2637
|
+
/** Frames that reached at least one session. See {@link stopRun}. */
|
|
2638
|
+
delivered = 0;
|
|
2639
|
+
lastRealAudioAt = 0;
|
|
2640
|
+
wanted = false;
|
|
2641
|
+
constructor(options) {
|
|
2642
|
+
this.options = options;
|
|
2643
|
+
this.frameMs = options.frameMs ?? 20;
|
|
2644
|
+
this.resumeGapMs = options.resumeGapMs ?? DEFAULT_RESUME_GAP_MS;
|
|
2645
|
+
this.tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
|
|
2646
|
+
this.ssrc = Math.random() * 4294967295 >>> 0;
|
|
2647
|
+
this.sequenceNumber = Math.random() * 65535 & 65535;
|
|
2648
|
+
}
|
|
2649
|
+
/** The audio shape this filler was built for. */
|
|
2650
|
+
get track() {
|
|
2651
|
+
return this.options.track;
|
|
2652
|
+
}
|
|
2653
|
+
/** True while packets are actually being produced. */
|
|
2654
|
+
get running() {
|
|
2655
|
+
return this.timer !== void 0;
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* Somebody wants the track filled. Idempotent — reconciliation calls this on
|
|
2659
|
+
* every session edge, and a second call while a run is in flight must not
|
|
2660
|
+
* restart the clock or re-log.
|
|
2661
|
+
*/
|
|
2662
|
+
want() {
|
|
2663
|
+
this.wanted = true;
|
|
2664
|
+
this.startRun();
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* Nobody wants the track filled any more (the last sentry left, the operator
|
|
2668
|
+
* muted the device, the broker is going away). Idempotent.
|
|
2669
|
+
*/
|
|
2670
|
+
release(reason) {
|
|
2671
|
+
this.wanted = false;
|
|
2672
|
+
this.clearResume();
|
|
2673
|
+
this.stopRun(reason);
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* The camera's own audio just arrived.
|
|
2677
|
+
*
|
|
2678
|
+
* Synchronous, and the first thing `pushAudioRtpPassthrough` does: the timer
|
|
2679
|
+
* is gone before this packet is written to any session, so no generated frame
|
|
2680
|
+
* can follow it. Called on EVERY real packet, not only the first — the rolling
|
|
2681
|
+
* gap is what lets the filler come back when the camera sleeps again, without
|
|
2682
|
+
* a second signal anybody has to remember to send.
|
|
2683
|
+
*/
|
|
2684
|
+
noteRealAudio(nowMs) {
|
|
2685
|
+
this.lastRealAudioAt = nowMs;
|
|
2686
|
+
this.stopRun("real audio");
|
|
2687
|
+
this.armResume();
|
|
2688
|
+
}
|
|
2689
|
+
startRun() {
|
|
2690
|
+
if (this.timer !== void 0) return;
|
|
2691
|
+
if (!this.wanted) return;
|
|
2692
|
+
if (Date.now() - this.lastRealAudioAt < this.resumeGapMs) {
|
|
2693
|
+
this.armResume();
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
this.rtpTimestamp = Math.floor(Date.now() * this.options.track.clockRateHz / 1e3) >>> 0;
|
|
2697
|
+
this.markNext = true;
|
|
2698
|
+
this.emitted = 0;
|
|
2699
|
+
this.delivered = 0;
|
|
2700
|
+
this.timer = setInterval(() => this.tick(), this.frameMs);
|
|
2701
|
+
this.options.logger?.info("sentry audio: filling the track with silence", {
|
|
2702
|
+
...this.tags,
|
|
2703
|
+
meta: {
|
|
2704
|
+
codec: this.options.track.codec,
|
|
2705
|
+
payloadType: this.options.track.payloadType,
|
|
2706
|
+
frameMs: this.frameMs,
|
|
2707
|
+
clockRateHz: this.options.track.clockRateHz
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
stopRun(reason) {
|
|
2712
|
+
if (this.timer === void 0) return;
|
|
2713
|
+
clearInterval(this.timer);
|
|
2714
|
+
this.timer = void 0;
|
|
2715
|
+
this.options.logger?.info("sentry audio: stopped filling the track", {
|
|
2716
|
+
...this.tags,
|
|
2717
|
+
meta: {
|
|
2718
|
+
reason,
|
|
2719
|
+
framesEmitted: this.emitted,
|
|
2720
|
+
framesDelivered: this.delivered
|
|
2721
|
+
}
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* Re-check once the gap has elapsed.
|
|
2726
|
+
*
|
|
2727
|
+
* A single pending timeout rather than a free-running ticker that decides per
|
|
2728
|
+
* frame: while the camera is awake this costs one timer per second instead of
|
|
2729
|
+
* fifty, and while it is asleep it costs nothing at all — the run is the
|
|
2730
|
+
* ticker.
|
|
2731
|
+
*/
|
|
2732
|
+
armResume() {
|
|
2733
|
+
if (!this.wanted) return;
|
|
2734
|
+
if (this.resumeTimer !== void 0) return;
|
|
2735
|
+
this.resumeTimer = setTimeout(() => {
|
|
2736
|
+
this.resumeTimer = void 0;
|
|
2737
|
+
this.startRun();
|
|
2738
|
+
}, this.resumeGapMs);
|
|
2739
|
+
this.resumeTimer.unref?.();
|
|
2740
|
+
}
|
|
2741
|
+
clearResume() {
|
|
2742
|
+
if (this.resumeTimer === void 0) return;
|
|
2743
|
+
clearTimeout(this.resumeTimer);
|
|
2744
|
+
this.resumeTimer = void 0;
|
|
2745
|
+
}
|
|
2746
|
+
tick() {
|
|
2747
|
+
const rtp = buildG711SilenceRtp({
|
|
2748
|
+
track: this.options.track,
|
|
2749
|
+
ssrc: this.ssrc,
|
|
2750
|
+
sequenceNumber: this.sequenceNumber,
|
|
2751
|
+
rtpTimestamp: this.rtpTimestamp,
|
|
2752
|
+
marker: this.markNext
|
|
2753
|
+
});
|
|
2754
|
+
this.sequenceNumber = this.sequenceNumber + 1 & 65535;
|
|
2755
|
+
this.rtpTimestamp = this.rtpTimestamp + 160 >>> 0;
|
|
2756
|
+
this.markNext = false;
|
|
2757
|
+
this.emitted += 1;
|
|
2758
|
+
if (this.options.emit(rtp) > 0) this.delivered += 1;
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2549
2761
|
/**
|
|
2550
2762
|
* Extract the `m=audio` media block from an ffmpeg-generated SDP, normalised
|
|
2551
2763
|
* for grafting (port 0, non-colliding payload type, own `a=control`, explicit
|
|
@@ -2593,7 +2805,7 @@ function appendAudioMediaBlock(videoSdp, audioBlock) {
|
|
|
2593
2805
|
* the result onto its counters and its `continue`.
|
|
2594
2806
|
*/
|
|
2595
2807
|
function admitJoinBurst(input) {
|
|
2596
|
-
if (input.
|
|
2808
|
+
if (input.liveEdgeOnly) return !input.fromRing || input.spanMs > input.liveEdgeMaxSpanMs ? "live-edge" : "none";
|
|
2597
2809
|
if (!input.detectionIntent && input.spanMs > input.bound.maxSpanMs) return "span";
|
|
2598
2810
|
if (input.packets > input.bound.maxPackets) return "packets";
|
|
2599
2811
|
return "none";
|
|
@@ -2795,6 +3007,14 @@ var LIVE_EDGE_MAX_SPAN_MS = 500;
|
|
|
2795
3007
|
*/
|
|
2796
3008
|
var PRIME_MAX_SPAN_MS = 2e3;
|
|
2797
3009
|
/**
|
|
3010
|
+
* The `a=control:trackID=` the sentry's grafted audio block is given.
|
|
3011
|
+
*
|
|
3012
|
+
* The same id the transcode egress grafts its own audio onto
|
|
3013
|
+
* (`extractAudioMediaBlock`'s default) and the same one `sendAudioRtp` writes
|
|
3014
|
+
* to (`tracks[1]`), because the video track is always trackID=0.
|
|
3015
|
+
*/
|
|
3016
|
+
var SENTRY_AUDIO_TRACK_ID = 1;
|
|
3017
|
+
/**
|
|
2798
3018
|
* Defensive packet ceiling on a join burst, independent of {@link
|
|
2799
3019
|
* PRIME_MAX_SPAN_MS}. The ring is trimmed to one GOP by RTP timestamp, so a
|
|
2800
3020
|
* burst this large means the timestamps stopped describing the stream (wrap,
|
|
@@ -2887,6 +3107,27 @@ var RtspRestreamer = class {
|
|
|
2887
3107
|
* a broker going idle overnight would wake up distributing audio again.
|
|
2888
3108
|
*/
|
|
2889
3109
|
deviceAudioMuted = false;
|
|
3110
|
+
/**
|
|
3111
|
+
* The camera's audio shape, remembered so a SENTRY can be handed a track to
|
|
3112
|
+
* fill while the camera is asleep.
|
|
3113
|
+
*
|
|
3114
|
+
* NOT cleared by {@link resetStreamState}, for exactly the reason
|
|
3115
|
+
* {@link deviceAudioMuted} is not: that method resets what belongs to one
|
|
3116
|
+
* CONNECTION, and this describes the CAMERA. A sleeping camera has no SDP to
|
|
3117
|
+
* read — it is the whole premise of the sentry — so if this did not survive
|
|
3118
|
+
* the suspend there would never be an audio track to announce at the moment
|
|
3119
|
+
* one is needed.
|
|
3120
|
+
*
|
|
3121
|
+
* `null` means nothing has ever described this camera's audio to us (a cold
|
|
3122
|
+
* hub whose battery camera has not been awake since boot), or its codec is
|
|
3123
|
+
* one whose silence cannot be generated exactly. Both are logged; neither is
|
|
3124
|
+
* guessed at.
|
|
3125
|
+
*/
|
|
3126
|
+
sentryAudioTrack = null;
|
|
3127
|
+
/** Fills a sentry's audio track while the camera sleeps. Built on demand. */
|
|
3128
|
+
sentryAudioSilence = null;
|
|
3129
|
+
/** One-shot guard for "a sentry attached and there is no track to fill". */
|
|
3130
|
+
sentryAudioGapLogged = false;
|
|
2890
3131
|
/** Cached last keyframe NALs (SPS/PPS + IDR) for late-joining clients. */
|
|
2891
3132
|
lastKeyframeNals = [];
|
|
2892
3133
|
lastKeyframeNalBytes = 0;
|
|
@@ -3095,6 +3336,156 @@ var RtspRestreamer = class {
|
|
|
3095
3336
|
return this.mutedSdp ?? this.sdp;
|
|
3096
3337
|
}
|
|
3097
3338
|
/**
|
|
3339
|
+
* The SDP a SENTRY dial is answered with: the picture it is being served,
|
|
3340
|
+
* plus the audio track that picture will be accompanied by.
|
|
3341
|
+
*
|
|
3342
|
+
* Three cases, in this order and no other:
|
|
3343
|
+
*
|
|
3344
|
+
* 1. The operator muted this camera broker-side (D83). That mute IS a device
|
|
3345
|
+
* policy and outranks everything here — the sentry is answered video-only,
|
|
3346
|
+
* exactly as every other consumer is.
|
|
3347
|
+
* 2. The current connection already describes audio (the camera is up and we
|
|
3348
|
+
* are serving its own SDP). It is served verbatim: grafting on top would
|
|
3349
|
+
* announce the track twice, and the camera's own description is by
|
|
3350
|
+
* definition the one its RTP will match.
|
|
3351
|
+
* 3. Otherwise — the sleeping case, where the SDP is built from the
|
|
3352
|
+
* placeholder frame and has no audio at all — the retained shape is
|
|
3353
|
+
* grafted on as trackID=1.
|
|
3354
|
+
*
|
|
3355
|
+
* Without a retained shape this degrades to the video-only SDP, which is what
|
|
3356
|
+
* the whole path used to serve unconditionally. That is a REFUSAL, reported
|
|
3357
|
+
* by {@link reconcileSentryAudio}: a sentry with no audio is a sentry that
|
|
3358
|
+
* will never have any, and the operator has to be able to see it rather than
|
|
3359
|
+
* infer it from a silent stream.
|
|
3360
|
+
*/
|
|
3361
|
+
getSentrySdp() {
|
|
3362
|
+
if (this.deviceAudioMuted) return this.getMutedSdp();
|
|
3363
|
+
const full = this.sdp;
|
|
3364
|
+
if (full !== null && /^m=audio/m.test(full)) return full;
|
|
3365
|
+
const videoOnly = this.getMutedSdp();
|
|
3366
|
+
const track = this.sentryAudioTrack;
|
|
3367
|
+
if (track === null || videoOnly === null) return videoOnly;
|
|
3368
|
+
return appendAudioMediaBlock(videoOnly, g711AudioMediaBlock(track, SENTRY_AUDIO_TRACK_ID));
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* The SDP for a parsed dial's variant.
|
|
3372
|
+
*
|
|
3373
|
+
* One switch and not three call sites: the listen server reads the SDP twice
|
|
3374
|
+
* (once to wait for it, once to answer with it), and those two reads
|
|
3375
|
+
* disagreeing about which variant a dial gets is a class of bug that costs a
|
|
3376
|
+
* consumer an empty DESCRIBE.
|
|
3377
|
+
*/
|
|
3378
|
+
getSdpFor(variant) {
|
|
3379
|
+
if (variant === "sentry") return this.getSentrySdp();
|
|
3380
|
+
if (variant === "muted") return this.getMutedSdp();
|
|
3381
|
+
return this.getSdp();
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Remember (or forget) the camera's audio shape for the sentry.
|
|
3385
|
+
*
|
|
3386
|
+
* Called from the broker's `onAudioTrack`, which is the only place the
|
|
3387
|
+
* camera's own SDP is parsed. `null` forgets it — a camera whose audio was
|
|
3388
|
+
* turned off, or whose codec this system cannot fill exactly.
|
|
3389
|
+
*/
|
|
3390
|
+
retainSentryAudioTrack(track) {
|
|
3391
|
+
const prior = this.sentryAudioTrack;
|
|
3392
|
+
if (prior?.codec === track?.codec && prior?.payloadType === track?.payloadType) return;
|
|
3393
|
+
this.sentryAudioTrack = track;
|
|
3394
|
+
this.sentryAudioSilence?.release("audio shape changed");
|
|
3395
|
+
this.sentryAudioSilence = null;
|
|
3396
|
+
this.sentryAudioGapLogged = false;
|
|
3397
|
+
this.logger?.info("sentry audio: track shape retained", {
|
|
3398
|
+
tags: { deviceId: this.deviceId },
|
|
3399
|
+
meta: {
|
|
3400
|
+
streamName: this.streamName,
|
|
3401
|
+
codec: track?.codec ?? null
|
|
3402
|
+
}
|
|
3403
|
+
});
|
|
3404
|
+
this.reconcileSentryAudio();
|
|
3405
|
+
}
|
|
3406
|
+
/** The retained audio shape, or `null`. Survives a suspend by design. */
|
|
3407
|
+
getSentryAudioTrack() {
|
|
3408
|
+
return this.sentryAudioTrack;
|
|
3409
|
+
}
|
|
3410
|
+
/**
|
|
3411
|
+
* Whether generated silence is being produced for this stream right now.
|
|
3412
|
+
*
|
|
3413
|
+
* Exposed because "is the filler still running" is not answerable from the
|
|
3414
|
+
* wire: a run whose last sentry has left writes to nobody, so a socket that
|
|
3415
|
+
* sees nothing looks exactly like a timer that was released and exactly like
|
|
3416
|
+
* one that was leaked. That ambiguity survived a deliberate sabotage of
|
|
3417
|
+
* {@link removeSession}'s reconcile with the whole suite green.
|
|
3418
|
+
*/
|
|
3419
|
+
isSentryAudioFilling() {
|
|
3420
|
+
return this.sentryAudioSilence?.running === true;
|
|
3421
|
+
}
|
|
3422
|
+
/**
|
|
3423
|
+
* Start or stop filling the sentry audio track to match who is attached.
|
|
3424
|
+
*
|
|
3425
|
+
* Called on every edge that can change the answer — a session arriving or
|
|
3426
|
+
* leaving, the operator's mute, a new audio shape — and never on a per-packet
|
|
3427
|
+
* path. Idempotent in both directions.
|
|
3428
|
+
*/
|
|
3429
|
+
reconcileSentryAudio() {
|
|
3430
|
+
if (!(this.getPrestreamSessionCount() > 0 && !this.deviceAudioMuted)) {
|
|
3431
|
+
this.sentryAudioSilence?.release("no sentry session");
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
const track = this.sentryAudioTrack;
|
|
3435
|
+
if (track === null) {
|
|
3436
|
+
if (!this.sentryAudioGapLogged) {
|
|
3437
|
+
this.sentryAudioGapLogged = true;
|
|
3438
|
+
this.logger?.info("sentry audio: no track to fill — this dial gets no audio", {
|
|
3439
|
+
tags: { deviceId: this.deviceId },
|
|
3440
|
+
meta: {
|
|
3441
|
+
streamName: this.streamName,
|
|
3442
|
+
reason: "no retained g711 audio shape"
|
|
3443
|
+
}
|
|
3444
|
+
});
|
|
3445
|
+
}
|
|
3446
|
+
return;
|
|
3447
|
+
}
|
|
3448
|
+
if (this.sentryAudioSilence === null) this.sentryAudioSilence = new SentryAudioSilence({
|
|
3449
|
+
track,
|
|
3450
|
+
emit: (rtp) => this.sendSilenceToSentrySessions(rtp),
|
|
3451
|
+
...this.logger ? { logger: this.logger } : {},
|
|
3452
|
+
...this.deviceId === void 0 ? {} : { deviceId: this.deviceId }
|
|
3453
|
+
});
|
|
3454
|
+
this.sentryAudioSilence.want();
|
|
3455
|
+
}
|
|
3456
|
+
/**
|
|
3457
|
+
* Hand one generated packet to every sentry session that can take it.
|
|
3458
|
+
*
|
|
3459
|
+
* Gated on PLAY, on the sentry intent and on the session's own mute — and
|
|
3460
|
+
* deliberately NOT on `pendingKeyframe`, which is the one gate the real audio
|
|
3461
|
+
* path applies and this one must not.
|
|
3462
|
+
*
|
|
3463
|
+
* That gate exists to keep live audio behind a video join REPLAY, so a
|
|
3464
|
+
* session's two tracks are not handed media from two different moments.
|
|
3465
|
+
* Generated silence belongs to no replay and to no GOP. Worse, waiting for
|
|
3466
|
+
* the video would reproduce the defect: the placeholder picture arrives at
|
|
3467
|
+
* 1 fps at best and not at all when the sentry refuses (unknown geometry, no
|
|
3468
|
+
* cached snapshot), so the track a consumer's ffmpeg is waiting on would stay
|
|
3469
|
+
* empty for exactly as long as the picture did — which is the stall the
|
|
3470
|
+
* announced-but-unfilled `m=audio` was feared for in the first place.
|
|
3471
|
+
*
|
|
3472
|
+
* Returns how many took it, so a run that delivers to nobody is visible.
|
|
3473
|
+
*/
|
|
3474
|
+
sendSilenceToSentrySessions(rtp) {
|
|
3475
|
+
const packet = {
|
|
3476
|
+
data: rtp,
|
|
3477
|
+
marker: (rtp[1] & 128) !== 0
|
|
3478
|
+
};
|
|
3479
|
+
let served = 0;
|
|
3480
|
+
for (const session of this.sessions.values()) {
|
|
3481
|
+
if (!session.isPrestreamIntent()) continue;
|
|
3482
|
+
if (session.isMuted()) continue;
|
|
3483
|
+
if (!session.isPlaying()) continue;
|
|
3484
|
+
if (session.sendAudioRtp(packet) === "written") served += 1;
|
|
3485
|
+
}
|
|
3486
|
+
return served;
|
|
3487
|
+
}
|
|
3488
|
+
/**
|
|
3098
3489
|
* Apply the broker-side audio mute. Returns how many audio-bearing pull
|
|
3099
3490
|
* sessions were closed so they re-`DESCRIBE` against the new SDP.
|
|
3100
3491
|
*
|
|
@@ -3106,6 +3497,7 @@ var RtspRestreamer = class {
|
|
|
3106
3497
|
setAudioMuted(muted) {
|
|
3107
3498
|
if (this.deviceAudioMuted === muted) return 0;
|
|
3108
3499
|
this.deviceAudioMuted = muted;
|
|
3500
|
+
this.reconcileSentryAudio();
|
|
3109
3501
|
const audioBearing = [...this.sessions.values()].filter((s) => !s.isMuted());
|
|
3110
3502
|
for (const session of audioBearing) this.killSession(session.getSessionId());
|
|
3111
3503
|
if (audioBearing.length > 0) this.logger?.info("audio mute changed — closing audio-bearing RTSP pulls so they re-DESCRIBE", {
|
|
@@ -3157,6 +3549,7 @@ var RtspRestreamer = class {
|
|
|
3157
3549
|
total: this.sessions.size
|
|
3158
3550
|
} });
|
|
3159
3551
|
this._onSessionCountChanged?.();
|
|
3552
|
+
this.reconcileSentryAudio();
|
|
3160
3553
|
if (this._rtpPassthroughMode) setTimeout(() => this.servePendingSessionsRtp(), 0);
|
|
3161
3554
|
else if (this.lastKeyframeNals.length > 0 && this.packetizer) setTimeout(() => this.servePendingSessions(), 0);
|
|
3162
3555
|
}
|
|
@@ -3332,7 +3725,7 @@ var RtspRestreamer = class {
|
|
|
3332
3725
|
}
|
|
3333
3726
|
}
|
|
3334
3727
|
const withhold = admitJoinBurst({
|
|
3335
|
-
|
|
3728
|
+
liveEdgeOnly: session.isLiveEdgeOnly(),
|
|
3336
3729
|
detectionIntent,
|
|
3337
3730
|
fromRing,
|
|
3338
3731
|
spanMs,
|
|
@@ -3595,6 +3988,7 @@ var RtspRestreamer = class {
|
|
|
3595
3988
|
*/
|
|
3596
3989
|
pushAudioRtpPassthrough(rtpData) {
|
|
3597
3990
|
if (this.deviceAudioMuted) return;
|
|
3991
|
+
this.sentryAudioSilence?.noteRealAudio(Date.now());
|
|
3598
3992
|
if (this.sessions.size === 0) return;
|
|
3599
3993
|
const rtpPacket = {
|
|
3600
3994
|
data: rtpData,
|
|
@@ -3615,6 +4009,7 @@ var RtspRestreamer = class {
|
|
|
3615
4009
|
total: this.sessions.size
|
|
3616
4010
|
} });
|
|
3617
4011
|
this._onSessionCountChanged?.();
|
|
4012
|
+
this.reconcileSentryAudio();
|
|
3618
4013
|
}
|
|
3619
4014
|
/**
|
|
3620
4015
|
* Force-close + remove a session by id. Used by the admin `killClient`
|
|
@@ -3643,6 +4038,7 @@ var RtspRestreamer = class {
|
|
|
3643
4038
|
}
|
|
3644
4039
|
destroy() {
|
|
3645
4040
|
this._onSessionCountChanged = null;
|
|
4041
|
+
this.sentryAudioSilence?.release("restreamer destroyed");
|
|
3646
4042
|
this.abortAllReplays();
|
|
3647
4043
|
this.deframer?.flush();
|
|
3648
4044
|
for (const session of this.sessions.values()) session.close();
|
|
@@ -3812,9 +4208,11 @@ var RtspSession = class {
|
|
|
3812
4208
|
* {@link NO_DECLARATION} when it declared nothing — which is every consumer
|
|
3813
4209
|
* except the per-session detection decode and the recorder's writer.
|
|
3814
4210
|
*
|
|
3815
|
-
* Kept separate from {@link muted} deliberately:
|
|
3816
|
-
*
|
|
3817
|
-
*
|
|
4211
|
+
* Kept separate from {@link muted} deliberately: the two say different things
|
|
4212
|
+
* and, since D525, they no longer coincide — a `/sentry` dial declares an
|
|
4213
|
+
* intent and is NOT muted, while the join rule that used to read `isMuted()`
|
|
4214
|
+
* now reads {@link isLiveEdgeOnly} and keeps every older population's
|
|
4215
|
+
* behaviour byte-for-byte.
|
|
3818
4216
|
*
|
|
3819
4217
|
* One field and not two, because the purpose and the amount are one
|
|
3820
4218
|
* statement (D382): a session that names a purpose without naming an amount
|
|
@@ -3876,6 +4274,20 @@ var RtspSession = class {
|
|
|
3876
4274
|
return this.muted;
|
|
3877
4275
|
}
|
|
3878
4276
|
/**
|
|
4277
|
+
* Whether this session is a RE-CLOCKING leg that may only join at the live
|
|
4278
|
+
* edge — the second question `isMuted()` used to answer.
|
|
4279
|
+
*
|
|
4280
|
+
* Derived from the one authority (`isLiveEdgeOnlyDial`) rather than stored,
|
|
4281
|
+
* so the rule cannot drift between the parser and the session. Unchanged for
|
|
4282
|
+
* every population that existed before the sentry gained audio.
|
|
4283
|
+
*/
|
|
4284
|
+
isLiveEdgeOnly() {
|
|
4285
|
+
return isLiveEdgeOnlyDial({
|
|
4286
|
+
muted: this.muted,
|
|
4287
|
+
declaration: this.declaration
|
|
4288
|
+
});
|
|
4289
|
+
}
|
|
4290
|
+
/**
|
|
3879
4291
|
* The `User-Agent` this client put on its first request, or `null`.
|
|
3880
4292
|
*
|
|
3881
4293
|
* The ONLY identity that crosses from a consumer process into this session.
|
|
@@ -4325,7 +4737,9 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4325
4737
|
async dispatch(socket, requestBuffer) {
|
|
4326
4738
|
const uriMatch = (requestBuffer.split("\r\n")[0] ?? "").match(/\s(rtsp:\/\/[^\s]+)\s/);
|
|
4327
4739
|
const pathMatch = (uriMatch ? uriMatch[1] : "").match(/rtsp:\/\/[^/]+(\/.*?)(?:\s|$)/);
|
|
4328
|
-
const
|
|
4740
|
+
const parsed = parseRestreamPath(pathMatch ? pathMatch[1].replace(/^\//, "").replace(/\/trackID=\d+$/, "") : "");
|
|
4741
|
+
const { lookupPath, muted: isMuted, declaration } = parsed;
|
|
4742
|
+
const sdpVariant = sdpVariantFor(parsed);
|
|
4329
4743
|
const restreamer = this.restreamers.get(lookupPath);
|
|
4330
4744
|
if (!restreamer) {
|
|
4331
4745
|
socket.write(`RTSP/1.0 404 Not Found\r\nCSeq: 1\r\n\r\n`);
|
|
@@ -4352,8 +4766,8 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4352
4766
|
};
|
|
4353
4767
|
socket.once("close", releaseOnce);
|
|
4354
4768
|
socket.once("error", releaseOnce);
|
|
4355
|
-
await this.waitForSdp(restreamer,
|
|
4356
|
-
const session = new RtspSession(socket,
|
|
4769
|
+
await this.waitForSdp(restreamer, sdpVariant, 3e3);
|
|
4770
|
+
const session = new RtspSession(socket, restreamer.getSdpFor(sdpVariant) ?? restreamer.getSdp() ?? "", () => {
|
|
4357
4771
|
restreamer.removeSession(session.getSessionId());
|
|
4358
4772
|
releaseOnce();
|
|
4359
4773
|
}, isMuted, declaration);
|
|
@@ -4368,10 +4782,10 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4368
4782
|
* a cold broker that's typically ~200-1000 ms. Resolves `true` if the
|
|
4369
4783
|
* SDP becomes non-empty within the budget, `false` on timeout.
|
|
4370
4784
|
*/
|
|
4371
|
-
async waitForSdp(restreamer,
|
|
4785
|
+
async waitForSdp(restreamer, variant, timeoutMs) {
|
|
4372
4786
|
const start = Date.now();
|
|
4373
4787
|
const have = () => {
|
|
4374
|
-
const s =
|
|
4788
|
+
const s = restreamer.getSdpFor(variant) ?? restreamer.getSdp();
|
|
4375
4789
|
return !!s && s.length > 0;
|
|
4376
4790
|
};
|
|
4377
4791
|
if (have()) return true;
|
|
@@ -15018,6 +15432,12 @@ var StreamBroker = class StreamBroker {
|
|
|
15018
15432
|
supportedNative
|
|
15019
15433
|
}
|
|
15020
15434
|
});
|
|
15435
|
+
const sentryTrack = g711TrackFor(codecUpper);
|
|
15436
|
+
this.rtspRestreamer.retainSentryAudioTrack(sentryTrack);
|
|
15437
|
+
if (sentryTrack === null) this.logger?.info("sentry audio: codec cannot be filled — a sentry gets no audio", {
|
|
15438
|
+
tags: { deviceId: this.numericDeviceId },
|
|
15439
|
+
meta: { codec: codecUpper }
|
|
15440
|
+
});
|
|
15021
15441
|
let supported = supportedNative;
|
|
15022
15442
|
let effectiveSampleRate = audioTrack.clockRate;
|
|
15023
15443
|
let effectiveChannels = audioTrack.channels;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.259",
|
|
4
4
|
"description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|