@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.
@@ -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-B0GOuR-F.js");
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 { a as preRollRequestOf, o as withDetectionIntent, t as DETECTION_PRE_ROLL_REQUEST_MS } from "../restream-intent-CgyZI565.mjs";
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";
@@ -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-B0GOuR-F.js");
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");
@@ -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 { l as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-CgyZI565.mjs";
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: true,
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 { preRollRequestOf as a, withSentryIntent as c, prebufferRetentionMs as d, resolvePrebuffer as f, parseRestreamPath as i, PREBUFFER_MAX_MS as l, NO_DECLARATION as n, withDetectionIntent as o, RECORDING_PRE_ROLL_REQUEST_MS as r, withRecordingIntent as s, DETECTION_PRE_ROLL_REQUEST_MS as t, capPreRollRequestMs as u };
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: true,
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-B0GOuR-F.js");
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.muted && !input.detectionIntent) return !input.fromRing || input.spanMs > input.liveEdgeMaxSpanMs ? "live-edge" : "none";
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
- muted: session.isMuted(),
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: `isMuted()` is shared by the
3820
- * WebRTC/HomeKit loopback legs, and the join rule that keys on it must stay
3821
- * true for them. Only an explicit declaration changes behaviour.
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 { lookupPath, muted: isMuted, declaration } = require_restream_intent.parseRestreamPath(pathMatch ? pathMatch[1].replace(/^\//, "").replace(/\/trackID=\d+$/, "") : "");
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, isMuted, 3e3);
4360
- const session = new RtspSession(socket, isMuted ? restreamer.getMutedSdp() ?? restreamer.getSdp() ?? "" : restreamer.getSdp() ?? "", () => {
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, isMuted, timeoutMs) {
4789
+ async waitForSdp(restreamer, variant, timeoutMs) {
4376
4790
  const start = Date.now();
4377
4791
  const have = () => {
4378
- const s = isMuted ? restreamer.getMutedSdp() ?? restreamer.getSdp() : restreamer.getSdp();
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 { c as withSentryIntent, d as prebufferRetentionMs, f as resolvePrebuffer, i as parseRestreamPath, l as PREBUFFER_MAX_MS, n as NO_DECLARATION, u as capPreRollRequestMs } from "../restream-intent-CgyZI565.mjs";
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.muted && !input.detectionIntent) return !input.fromRing || input.spanMs > input.liveEdgeMaxSpanMs ? "live-edge" : "none";
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
- muted: session.isMuted(),
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: `isMuted()` is shared by the
3816
- * WebRTC/HomeKit loopback legs, and the join rule that keys on it must stay
3817
- * true for them. Only an explicit declaration changes behaviour.
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 { lookupPath, muted: isMuted, declaration } = parseRestreamPath(pathMatch ? pathMatch[1].replace(/^\//, "").replace(/\/trackID=\d+$/, "") : "");
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, isMuted, 3e3);
4356
- const session = new RtspSession(socket, isMuted ? restreamer.getMutedSdp() ?? restreamer.getSdp() ?? "" : restreamer.getSdp() ?? "", () => {
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, isMuted, timeoutMs) {
4785
+ async waitForSdp(restreamer, variant, timeoutMs) {
4372
4786
  const start = Date.now();
4373
4787
  const have = () => {
4374
- const s = isMuted ? restreamer.getMutedSdp() ?? restreamer.getSdp() : restreamer.getSdp();
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.258",
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",