@camstack/addon-pipeline 1.2.44 → 1.2.45

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.
Files changed (36) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +3 -2
  4. package/dist/detection-pipeline/index.mjs +3 -2
  5. package/dist/{dist-DOu93i_g.mjs → dist-BDkEhx4Z.mjs} +692 -123
  6. package/dist/{dist-BuFE5rOK.js → dist-QyZ7QSRP.js} +697 -122
  7. package/dist/{event-loop-stall-monitor-L08yG6bB.js → event-loop-stall-monitor-COEKVBQK.js} +14 -7
  8. package/dist/{event-loop-stall-monitor-CShgQE6l.mjs → event-loop-stall-monitor-IAuNEdmG.mjs} +14 -7
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +3 -3
  12. package/dist/pipeline-runner/index.mjs +3 -3
  13. package/dist/recorder/index.js +1 -1
  14. package/dist/recorder/index.mjs +1 -1
  15. package/dist/session-decode/decode-worker-child.js +1 -1
  16. package/dist/session-decode/decode-worker-child.mjs +1 -1
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DiXoZnou.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C68YfSUH.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CT2bGWqM.mjs +26 -0
  20. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B_hakWAb.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B3GxBnXM.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-khRXMnjl.mjs → hostInit-BMCrULpc.mjs} +2 -2
  22. package/dist/stream-broker/index.js +735 -424
  23. package/dist/stream-broker/index.mjs +731 -421
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-BOXlUhWO.mjs → worker-protocol-DFH_z2-q.mjs} +1 -1
  26. package/dist/{worker-protocol-VURr0nUh.js → worker-protocol-le27Mw_8.js} +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js → MaskShapeCanvas-DI4BY7W2-C7bsz4cP.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-jFDOzPCD.js → MotionZonesSettings-NcxxQN8r-BmhLinUd.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js → PrivacyMaskSettings-APgPLF7p-BJjyGWFy.js} +1 -1
  30. package/embed-dist/assets/{index-4CtQAybX.js → index-a4CMt-Uc.js} +12 -12
  31. package/embed-dist/index.html +1 -1
  32. package/package.json +1 -1
  33. package/python/postprocessors/__init__.py +2 -0
  34. package/python/postprocessors/rfdetr.py +161 -0
  35. package/python/postprocessors/test_rfdetr.py +193 -0
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BRc-m6W-.mjs +0 -26
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
2
- import { B as invocationFromEncodeProfile, C as addonWidgetsSourceCapability, Ct as literal, D as cameraStreamsCapability, Dt as string, E as buildFfmpegArgs$1, Et as record, F as egressTranscodeSharingKey, I as egressTransportFromRequest, O as createHwAccelCache, Ot as union, Tt as object, U as maskUrlCredentials, V as isSoftwareDecode, Z as resolveEgressDecodeHwAccel, at as CAM_PROFILE_ORDER, bt as boolean, ct as createEvent, dt as makeProfileBrokerId, et as streamBrokerCapability, f as EncodeProfileSchema, ft as makeSourceBrokerId, ht as parseProfileBrokerId, i as AUDIO_PRESETS, it as BaseAddon, kt as EventCategory, nt as webrtcSessionCapability, ot as DeviceFeature, pt as nodePin, rt as errMsg, st as DeviceType, ut as isEvent, v as RATE_CONTROL_RELAXED, vt as _enum, wt as number, x as RingBuffer, xt as discriminatedUnion, y as RATE_CONTROL_TIGHT, yt as array } from "../dist-DOu93i_g.mjs";
2
+ import { $ as errMsg, At as EventCategory, Dt as record, E as createHwAccelCache, Et as object, N as egressTranscodeSharingKey, Ot as string, P as egressTransportFromRequest, Q as webrtcSessionCapability, S as addonWidgetsSourceCapability, St as discriminatedUnion, T as cameraStreamsCapability, Tt as number, X as streamBrokerCapability, _ as RATE_CONTROL_RELAXED, at as BaseAddon, b as RingBuffer, bt as array, ct as DeviceType, d as EncodeProfileSchema, dt as isEvent, et as AUDIO_PRESETS, ft as makeProfileBrokerId, gt as parseProfileBrokerId, it as isSoftwareDecode, kt as union, lt as createEvent, mt as nodePin, nt as buildFfmpegArgs$1, ot as CAM_PROFILE_ORDER, pt as makeSourceBrokerId, q as resolveEgressDecodeHwAccel, rt as invocationFromEncodeProfile, st as DeviceFeature, tt as Fmp4BoxSplitter, v as RATE_CONTROL_TIGHT, wt as literal, xt as boolean, yt as _enum, z as maskUrlCredentials } from "../dist-BDkEhx4Z.mjs";
3
3
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
4
4
  import { n as createFileDataPlaneHandler } from "../model-download-service-D8B-4ktF-BB7oZL3y.mjs";
5
5
  import { createRequire } from "node:module";
@@ -10,6 +10,7 @@ import { pathToFileURL } from "node:url";
10
10
  import * as os from "node:os";
11
11
  import { networkInterfaces, tmpdir } from "node:os";
12
12
  import * as fs from "node:fs";
13
+ import "node:fs";
13
14
  import * as path$1 from "node:path";
14
15
  import path, { join } from "node:path";
15
16
  import * as net$1 from "node:net";
@@ -1569,6 +1570,21 @@ var RtspRestreamer = class {
1569
1570
  * trackID=1. Muted sessions still get the video-only SDP.
1570
1571
  */
1571
1572
  egressAudioBlock = null;
1573
+ /**
1574
+ * BROKER-SIDE AUDIO MUTE (D83) — device policy, set by `StreamBroker`.
1575
+ *
1576
+ * Distinct from a session's own `isMuted()`, which is a PULLER's choice
1577
+ * (the `/muted` URL a local decoder asks for). This one overrides every
1578
+ * puller: while true, `getSdp()` serves the video-only SDP to everyone, so
1579
+ * a client that connects during the mute never declares an audio stream it
1580
+ * will not receive.
1581
+ *
1582
+ * NOT cleared by {@link resetStreamState}. That method resets what belongs
1583
+ * to one CONNECTION (keyframe caches, codec params, SDP); an operator's
1584
+ * mute belongs to the device and must survive a suspend/resume — otherwise
1585
+ * a broker going idle overnight would wake up distributing audio again.
1586
+ */
1587
+ deviceAudioMuted = false;
1572
1588
  /** Cached last keyframe NALs (SPS/PPS + IDR) for late-joining clients. */
1573
1589
  lastKeyframeNals = [];
1574
1590
  lastKeyframeNalBytes = 0;
@@ -1731,11 +1747,40 @@ var RtspRestreamer = class {
1731
1747
  return this.codecParams;
1732
1748
  }
1733
1749
  getSdp() {
1750
+ if (this.deviceAudioMuted) return this.getMutedSdp();
1734
1751
  return this.sdp;
1735
1752
  }
1736
1753
  getMutedSdp() {
1737
1754
  return this.mutedSdp ?? this.sdp;
1738
1755
  }
1756
+ /**
1757
+ * Apply the broker-side audio mute. Returns how many audio-bearing pull
1758
+ * sessions were closed so they re-`DESCRIBE` against the new SDP.
1759
+ *
1760
+ * Idempotent — the same value twice closes nothing. Sessions that pulled the
1761
+ * `/muted` URL are left alone: they never had an audio track to gain or
1762
+ * lose, and killing a local decoder's feed to change nothing would be a
1763
+ * decode gap for free.
1764
+ */
1765
+ setAudioMuted(muted) {
1766
+ if (this.deviceAudioMuted === muted) return 0;
1767
+ this.deviceAudioMuted = muted;
1768
+ const audioBearing = [...this.sessions.values()].filter((s) => !s.isMuted());
1769
+ for (const session of audioBearing) this.killSession(session.getSessionId());
1770
+ if (audioBearing.length > 0) this.logger?.info("audio mute changed — closing audio-bearing RTSP pulls so they re-DESCRIBE", {
1771
+ tags: { deviceId: this.deviceId },
1772
+ meta: {
1773
+ streamName: this.streamName,
1774
+ muted,
1775
+ closed: audioBearing.length
1776
+ }
1777
+ });
1778
+ return audioBearing.length;
1779
+ }
1780
+ /** The broker-side audio mute currently in force for this restreamer. */
1781
+ isDeviceAudioMuted() {
1782
+ return this.deviceAudioMuted;
1783
+ }
1739
1784
  getSessionCount() {
1740
1785
  return this.sessions.size;
1741
1786
  }
@@ -2004,6 +2049,7 @@ var RtspRestreamer = class {
2004
2049
  * would mean dropping ~300 ms of audio at every join.
2005
2050
  */
2006
2051
  pushAudioRtpPassthrough(rtpData) {
2052
+ if (this.deviceAudioMuted) return;
2007
2053
  if (this.sessions.size === 0) return;
2008
2054
  const rtpPacket = {
2009
2055
  data: rtpData,
@@ -3184,182 +3230,291 @@ function pushDimension(into, constraint, requested, known) {
3184
3230
  reason: `source ${known} > requested ${requested}`
3185
3231
  });
3186
3232
  }
3187
- //#endregion
3188
- //#region src/stream-broker/stream-broker/fmp4-box-splitter.ts
3189
- var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
3190
- /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
3191
- var BOX_HEADER_BYTES = 8;
3192
- var LARGE_BOX_HEADER_BYTES = 16;
3193
- var Fmp4BoxSplitter = class {
3194
- maxUnitBytes;
3195
- /** Bytes of the CURRENT unit plus any partial box after it. */
3196
- buffer = new Uint8Array(0);
3197
- /** Where the current unit starts inside {@link buffer}. */
3198
- unitStart = 0;
3199
- /** Where the box scanner has reached inside {@link buffer}. */
3200
- cursor = 0;
3201
- state = "init";
3202
- nextSequence = 0;
3203
- faultReason = null;
3204
- interstitial = /* @__PURE__ */ new Set();
3205
- constructor(options = {}) {
3206
- this.maxUnitBytes = options.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES;
3207
- }
3208
- /**
3209
- * Non-null once the stream cannot be split. The splitter emits nothing
3210
- * further, so a caller polls this to kill the child rather than watching a
3211
- * silent stall a fragmenter that quietly stops producing looks exactly like
3212
- * a camera with no motion.
3213
- */
3214
- get fault() {
3215
- return this.faultReason;
3216
- }
3217
- /** Bytes currently held. The memory bound, observable rather than asserted. */
3218
- get pendingBytes() {
3219
- return this.buffer.length - this.unitStart;
3220
- }
3221
- /**
3222
- * Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
3223
- * a stray `sidx`. Reported rather than dropped in silence: they are legal and
3224
- * useless to a fragment consumer, but a type nobody expected showing up here
3225
- * is the first symptom of a muxer that is not writing what we think it is.
3226
- */
3227
- get discardedInterstitialTypes() {
3228
- return [...this.interstitial];
3229
- }
3230
- /**
3231
- * Feed bytes; get back whatever units completed. Returns `[]` once faulted.
3233
+ /**
3234
+ * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
3235
+ * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
3236
+ *
3237
+ * ## Why a plane and not a callback
3238
+ *
3239
+ * The operator's requirement for HKSV was explicit: the live fMP4 source built
3240
+ * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
3241
+ * CamStack as an additional videoclip source alongside the recorder and the NC
3242
+ * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
3243
+ * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
3244
+ * a second ffmpeg child of the same camera. So this is the same shape the
3245
+ * broker's other multi-consumer surfaces already have
3246
+ * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
3247
+ * one producer.
3248
+ *
3249
+ * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
3250
+ * clip source; both are named here so the seam is not re-invented, and neither
3251
+ * is built.
3252
+ *
3253
+ * ## The init segment is RETAINED
3254
+ *
3255
+ * A subscriber that attaches mid-stream the clip consumer joining an already
3256
+ * running HKSV session, which is the whole dual-use case receives the
3257
+ * retained `ftyp`+`moov` as its first packet and then live fragments. Without
3258
+ * retention its fragments are undecodable and the failure looks like a codec
3259
+ * problem.
3260
+ *
3261
+ * ## A slow subscriber is CLOSED, never silently gapped
3262
+ *
3263
+ * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
3264
+ * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
3265
+ * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
3266
+ * shows a clip that fails to play with nothing anywhere saying why. So a
3267
+ * subscription whose queue overflows is ENDED with a reason, loudly, and the
3268
+ * other subscriptions are untouched.
3269
+ *
3270
+ * ## The PREBUFFER (phase 3)
3271
+ *
3272
+ * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
3273
+ * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
3274
+ * motion edge has none. So the plane optionally retains the last few fragments
3275
+ * and replays them to a subscriber that asks for them.
3276
+ *
3277
+ * Three things this ring gets right, each of which is a measured fact rather
3278
+ * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
3279
+ *
3280
+ * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
3281
+ * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
3282
+ * window. A time-only bound is a per-camera RAM figure nobody can predict.
3283
+ * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
3284
+ * splitter deliberately never computes a fragment's duration (a second
3285
+ * opinion about a fact the muxer owns), and a prebuffer cares about how long
3286
+ * ago the bytes turned up, which is exactly what arrival time answers.
3287
+ * - **A replay is not backlog.** A subscriber taking N retained fragments gets
3288
+ * its queue capacity raised by N for them, because closing a subscriber as a
3289
+ * slow consumer for the prebuffer it explicitly asked for would be the
3290
+ * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
3291
+ * ring of 4, the guaranteed one.
3292
+ *
3293
+ * ## `isLast`
3294
+ *
3295
+ * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
3296
+ * `isLast` — a generator that finishes without it produces the twelve-second
3297
+ * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
3298
+ * plane therefore computes it at DELIVERY time: a packet is last when the plane
3299
+ * has ended and nothing remains queued behind it. A subscription that ends
3300
+ * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
3301
+ * the future delegate must not open an HDS stream it cannot feed.
3302
+ */
3303
+ var DEFAULT_QUEUE_CAPACITY = 4;
3304
+ var Fmp4FragmentPlane = class {
3305
+ logger;
3306
+ prebuffer;
3307
+ now;
3308
+ subscriptions = /* @__PURE__ */ new Map();
3309
+ /** The last init unit seen, handed to every later subscriber. */
3310
+ retainedInit = null;
3311
+ ended = false;
3312
+ /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
3313
+ ring = [];
3314
+ ringBytes = 0;
3315
+ constructor(logger, prebuffer, now = Date.now) {
3316
+ this.logger = logger;
3317
+ this.prebuffer = prebuffer;
3318
+ this.now = now;
3319
+ }
3320
+ get subscriberCount() {
3321
+ return this.subscriptions.size;
3322
+ }
3323
+ /** True once {@link end} has been called — no further units are accepted. */
3324
+ get isEnded() {
3325
+ return this.ended;
3326
+ }
3327
+ /** What the prebuffer ring holds right now. All zeroes when disabled. */
3328
+ prebufferStats() {
3329
+ const oldest = this.ring[0];
3330
+ return {
3331
+ fragments: this.ring.length,
3332
+ bytes: this.ringBytes,
3333
+ spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
3334
+ };
3335
+ }
3336
+ subscribe(input) {
3337
+ const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
3338
+ const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
3339
+ const sub = {
3340
+ id: `fmp4-${randomUUID()}`,
3341
+ tag: input.tag,
3342
+ subscribedAt: this.now(),
3343
+ capacity: requested + replay.length,
3344
+ queue: [],
3345
+ delivered: 0,
3346
+ closedReason: null,
3347
+ wake: null,
3348
+ iterating: false
3349
+ };
3350
+ this.subscriptions.set(sub.id, sub);
3351
+ if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
3352
+ for (const retained of replay) this.enqueue(sub, retained.unit);
3353
+ if (this.ended) this.closeSubscription(sub, "ended");
3354
+ this.logger?.info("fmp4 plane: subscribed", { meta: {
3355
+ subscriptionId: sub.id,
3356
+ tag: sub.tag,
3357
+ hasRetainedInit: this.retainedInit !== null,
3358
+ prebufferFragments: replay.length,
3359
+ prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
3360
+ } });
3361
+ return this.facade(sub);
3362
+ }
3363
+ /**
3364
+ * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
3365
+ * emits exactly one per child, and a second means the child was respawned, in
3366
+ * which case the old one describes a stream that no longer exists.
3232
3367
  */
3233
- push(chunk) {
3234
- if (this.faultReason !== null || chunk.length === 0) return [];
3235
- this.append(chunk);
3236
- if (this.pendingBytes > this.maxUnitBytes) return this.fail(`a single fMP4 unit exceeded ${this.maxUnitBytes} bytes — this stream is not fragmented`);
3237
- return this.drainBoxes();
3238
- }
3239
- append(chunk) {
3240
- if (this.buffer.length === 0) {
3241
- this.buffer = chunk.slice();
3242
- return;
3368
+ publish(unit) {
3369
+ if (this.ended) return;
3370
+ if (unit.kind === "init") {
3371
+ this.retainedInit = unit;
3372
+ this.ring.length = 0;
3373
+ this.ringBytes = 0;
3374
+ } else this.retain(unit);
3375
+ for (const sub of this.subscriptions.values()) {
3376
+ if (sub.closedReason !== null) continue;
3377
+ this.enqueue(sub, unit);
3243
3378
  }
3244
- const next = new Uint8Array(this.buffer.length + chunk.length);
3245
- next.set(this.buffer, 0);
3246
- next.set(chunk, this.buffer.length);
3247
- this.buffer = next;
3248
3379
  }
3249
- /** Consume every COMPLETE top-level box now in the buffer. */
3250
- drainBoxes() {
3251
- const units = [];
3252
- for (;;) {
3253
- const header = this.readHeader();
3254
- if (this.faultReason !== null) return units;
3255
- if (header === null) break;
3256
- if (this.cursor + header.totalBytes > this.buffer.length) break;
3257
- const boxStart = this.cursor;
3258
- const boxEnd = boxStart + header.totalBytes;
3259
- this.cursor = boxEnd;
3260
- const unit = this.consumeBox(header.type, boxStart, boxEnd);
3261
- if (this.faultReason !== null) return units;
3262
- if (unit !== null) units.push(unit);
3263
- }
3264
- this.compact();
3265
- return units;
3266
- }
3267
- /**
3268
- * Apply one box to the state machine. Returns a unit when this box CLOSED
3269
- * one, `null` otherwise.
3270
- */
3271
- consumeBox(type, boxStart, boxEnd) {
3272
- if (this.state === "init") {
3273
- if (type !== "moof") return null;
3274
- if (boxStart === this.unitStart) {
3275
- this.fail("a moof arrived before any initialisation box — there is no ftyp/moov to send");
3276
- return null;
3277
- }
3278
- const init = this.emit("init", this.unitStart, boxStart);
3279
- this.unitStart = boxStart;
3280
- this.state = "fragment";
3281
- return init;
3282
- }
3283
- if (this.state === "idle") {
3284
- if (type !== "moof") {
3285
- this.interstitial.add(type);
3286
- this.unitStart = boxEnd;
3287
- return null;
3288
- }
3289
- this.unitStart = boxStart;
3290
- this.state = "fragment";
3291
- return null;
3380
+ /**
3381
+ * The producer stopped. Every subscriber drains what it holds; its final
3382
+ * packet carries `isLast`, and its generator then completes.
3383
+ */
3384
+ end(reason = "producer ended") {
3385
+ if (this.ended) return;
3386
+ this.ended = true;
3387
+ this.logger?.info("fmp4 plane: ended", { meta: {
3388
+ reason,
3389
+ subscribers: this.subscriptions.size
3390
+ } });
3391
+ for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
3392
+ }
3393
+ listSubscribers() {
3394
+ return [...this.subscriptions.values()].map((s) => ({
3395
+ tag: s.tag,
3396
+ subscribedAt: s.subscribedAt,
3397
+ delivered: s.delivered,
3398
+ closedReason: s.closedReason
3399
+ }));
3400
+ }
3401
+ /** End and forget everything. Idempotent. */
3402
+ dispose() {
3403
+ this.end("disposed");
3404
+ this.subscriptions.clear();
3405
+ this.retainedInit = null;
3406
+ this.ring.length = 0;
3407
+ this.ringBytes = 0;
3408
+ }
3409
+ /**
3410
+ * Add one fragment to the ring and evict from the front until BOTH bounds
3411
+ * hold. Eviction is oldest-first, which is the one place in this file where
3412
+ * dropping is correct: the ring is context, not stream — nobody is mid-decode
3413
+ * on it, and a subscriber only ever receives a contiguous tail of it.
3414
+ */
3415
+ retain(unit) {
3416
+ const prebuffer = this.prebuffer;
3417
+ if (prebuffer === void 0) return;
3418
+ const arrivedAt = this.now();
3419
+ this.ring.push({
3420
+ unit,
3421
+ arrivedAt
3422
+ });
3423
+ this.ringBytes += unit.data.length;
3424
+ const cutoff = arrivedAt - prebuffer.windowMs;
3425
+ while (this.ring.length > 0) {
3426
+ const oldest = this.ring[0];
3427
+ if (oldest === void 0) break;
3428
+ const tooOld = oldest.arrivedAt < cutoff;
3429
+ const tooBig = this.ringBytes > prebuffer.maxBytes;
3430
+ if (!tooOld && !tooBig || this.ring.length === 1) break;
3431
+ this.ring.shift();
3432
+ this.ringBytes -= oldest.unit.data.length;
3292
3433
  }
3293
- if (type !== "mdat") return null;
3294
- const fragment = this.emit("fragment", this.unitStart, boxEnd);
3295
- this.unitStart = boxEnd;
3296
- this.state = "idle";
3297
- return fragment;
3298
3434
  }
3299
3435
  /**
3300
- * Parse the header at {@link cursor}, or `null` when too few bytes have
3301
- * arrived to know. Faults on a size the splitter cannot honour.
3436
+ * The ring as a subscriber should receive it window applied AT SUBSCRIBE
3437
+ * time, not only at publish time. A camera that went quiet keeps its last
3438
+ * fragment in the ring indefinitely (see the never-evict-the-newest rule),
3439
+ * and replaying a 40-second-old fragment as "prebuffer" would put stale video
3440
+ * at the head of a clip iOS presents as the moment of the event.
3302
3441
  */
3303
- readHeader() {
3304
- const available = this.buffer.length - this.cursor;
3305
- if (available < BOX_HEADER_BYTES) return null;
3306
- const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
3307
- const size = view.getUint32(this.cursor);
3308
- const type = String.fromCharCode(this.buffer[this.cursor + 4] ?? 0, this.buffer[this.cursor + 5] ?? 0, this.buffer[this.cursor + 6] ?? 0, this.buffer[this.cursor + 7] ?? 0);
3309
- if (size === 0) {
3310
- this.fail(`box "${type}" declares size 0 (to EOF) — an unbounded box cannot be fragmented`);
3311
- return null;
3312
- }
3313
- if (size === 1) {
3314
- if (available < LARGE_BOX_HEADER_BYTES) return null;
3315
- const large = view.getBigUint64(this.cursor + BOX_HEADER_BYTES);
3316
- if (large > BigInt(this.maxUnitBytes)) {
3317
- this.fail(`box "${type}" declares ${large} bytes, over the ${this.maxUnitBytes} byte bound`);
3318
- return null;
3319
- }
3320
- return {
3321
- type,
3322
- totalBytes: Number(large)
3323
- };
3324
- }
3325
- if (size < BOX_HEADER_BYTES) {
3326
- this.fail(`box "${type}" declares an impossible size of ${size} bytes`);
3327
- return null;
3442
+ trimmedRing() {
3443
+ const prebuffer = this.prebuffer;
3444
+ if (prebuffer === void 0) return [];
3445
+ const cutoff = this.now() - prebuffer.windowMs;
3446
+ return this.ring.filter((r) => r.arrivedAt >= cutoff);
3447
+ }
3448
+ enqueue(sub, unit) {
3449
+ if (sub.queue.length >= sub.capacity) {
3450
+ this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
3451
+ subscriptionId: sub.id,
3452
+ tag: sub.tag,
3453
+ capacity: sub.capacity,
3454
+ delivered: sub.delivered
3455
+ } });
3456
+ this.closeSubscription(sub, "slow-consumer");
3457
+ return;
3328
3458
  }
3329
- return {
3330
- type,
3331
- totalBytes: size
3332
- };
3459
+ sub.queue.push({
3460
+ kind: unit.kind,
3461
+ data: unit.data,
3462
+ sequence: unit.sequence,
3463
+ isLast: false
3464
+ });
3465
+ this.wake(sub);
3466
+ }
3467
+ closeSubscription(sub, reason) {
3468
+ if (sub.closedReason !== null) return;
3469
+ sub.closedReason = reason;
3470
+ if (reason === "slow-consumer") sub.queue.length = 0;
3471
+ this.wake(sub);
3472
+ }
3473
+ wake(sub) {
3474
+ const resume = sub.wake;
3475
+ sub.wake = null;
3476
+ resume?.();
3333
3477
  }
3334
- emit(kind, start, end) {
3335
- const sequence = this.nextSequence;
3336
- this.nextSequence += 1;
3478
+ facade(sub) {
3479
+ const plane = this;
3337
3480
  return {
3338
- kind,
3339
- data: this.buffer.slice(start, end),
3340
- sequence
3481
+ id: sub.id,
3482
+ tag: sub.tag,
3483
+ get delivered() {
3484
+ return sub.delivered;
3485
+ },
3486
+ get closedReason() {
3487
+ return sub.closedReason;
3488
+ },
3489
+ packets: () => plane.iterate(sub),
3490
+ release: () => {
3491
+ plane.closeSubscription(sub, "released");
3492
+ plane.subscriptions.delete(sub.id);
3493
+ }
3341
3494
  };
3342
3495
  }
3343
- /**
3344
- * Drop everything already emitted or discarded. Without this the buffer is
3345
- * the whole stream and the process dies in hours, not minutes.
3346
- */
3347
- compact() {
3348
- if (this.unitStart === 0) return;
3349
- this.buffer = this.buffer.slice(this.unitStart);
3350
- this.cursor -= this.unitStart;
3351
- this.unitStart = 0;
3352
- }
3353
- fail(reason) {
3354
- this.faultReason = reason;
3355
- this.buffer = new Uint8Array(0);
3356
- this.unitStart = 0;
3357
- this.cursor = 0;
3358
- return [];
3496
+ async *iterate(sub) {
3497
+ if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
3498
+ sub.iterating = true;
3499
+ for (;;) {
3500
+ const next = sub.queue.shift();
3501
+ if (next === void 0) {
3502
+ if (sub.closedReason !== null) return;
3503
+ await new Promise((resolve) => {
3504
+ sub.wake = resolve;
3505
+ });
3506
+ continue;
3507
+ }
3508
+ const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
3509
+ sub.delivered += 1;
3510
+ yield {
3511
+ ...next,
3512
+ isLast
3513
+ };
3514
+ if (isLast) return;
3515
+ }
3359
3516
  }
3360
3517
  };
3361
- //#endregion
3362
- //#region src/stream-broker/stream-broker/egress-fragment-child.ts
3363
3518
  var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
3364
3519
  /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
3365
3520
  var FRAGMENT_LOG_EVERY = 30;
@@ -3388,7 +3543,7 @@ var Fmp4FragmentChild = class {
3388
3543
  this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
3389
3544
  tags: { deviceId: this.args.deviceId },
3390
3545
  meta: {
3391
- brokerId: this.args.brokerId,
3546
+ sourceId: this.args.sourceId,
3392
3547
  decodeHwAccel: requested,
3393
3548
  error: errMsg(err)
3394
3549
  }
@@ -3424,7 +3579,7 @@ var Fmp4FragmentChild = class {
3424
3579
  this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
3425
3580
  tags: { deviceId: this.args.deviceId },
3426
3581
  meta: {
3427
- brokerId: this.args.brokerId,
3582
+ sourceId: this.args.sourceId,
3428
3583
  fragmentMs: this.args.fragmentMs,
3429
3584
  decodeHwAccel: decodeHwAccel ?? "software",
3430
3585
  argv: args.join(" ")
@@ -3478,7 +3633,7 @@ var Fmp4FragmentChild = class {
3478
3633
  this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
3479
3634
  tags: { deviceId: this.args.deviceId },
3480
3635
  meta: {
3481
- brokerId: this.args.brokerId,
3636
+ sourceId: this.args.sourceId,
3482
3637
  bytes: unit.data.length
3483
3638
  }
3484
3639
  });
@@ -3486,7 +3641,7 @@ var Fmp4FragmentChild = class {
3486
3641
  } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
3487
3642
  tags: { deviceId: this.args.deviceId },
3488
3643
  meta: {
3489
- brokerId: this.args.brokerId,
3644
+ sourceId: this.args.sourceId,
3490
3645
  unitsOut: this.unitsOut,
3491
3646
  bytes: unit.data.length,
3492
3647
  subscribers: this.args.plane.subscriberCount
@@ -3501,7 +3656,7 @@ var Fmp4FragmentChild = class {
3501
3656
  this.deps.logger.debug("fmp4 fragment child ffmpeg", {
3502
3657
  tags: { deviceId: this.args.deviceId },
3503
3658
  meta: {
3504
- brokerId: this.args.brokerId,
3659
+ sourceId: this.args.sourceId,
3505
3660
  line: line.trim()
3506
3661
  }
3507
3662
  });
@@ -3516,260 +3671,62 @@ var Fmp4FragmentChild = class {
3516
3671
  this.deps.onChildExit?.(err);
3517
3672
  });
3518
3673
  child.once("exit", (code, signal) => {
3519
- if (!settled) {
3520
- settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
3521
- return;
3522
- }
3523
- if (!isCurrent()) return;
3524
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
3525
- this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
3526
- tags: { deviceId: this.args.deviceId },
3527
- meta: {
3528
- brokerId: this.args.brokerId,
3529
- code,
3530
- signal,
3531
- unitsOut: this.unitsOut
3532
- }
3533
- });
3534
- this.args.plane.end("the fragment child exited");
3535
- this.deps.onChildExit?.(error);
3536
- });
3537
- });
3538
- }
3539
- /**
3540
- * The byte stream stopped being splittable. Not recoverable — the splitter
3541
- * cannot resynchronise mid-box — so the child is a corpse and every consumer
3542
- * has to be told, loudly, with the reason.
3543
- */
3544
- onFault(reason, wasLive, current, settle) {
3545
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
3546
- this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
3547
- tags: { deviceId: this.args.deviceId },
3548
- meta: {
3549
- brokerId: this.args.brokerId,
3550
- unitsOut: this.unitsOut,
3551
- interstitial: this.splitter.discardedInterstitialTypes,
3552
- reason
3553
- }
3554
- });
3555
- this.killChild();
3556
- settle(error);
3557
- if (wasLive && current) {
3558
- this.args.plane.end("the fragment child produced unsplittable output");
3559
- this.deps.onChildExit?.(error);
3560
- }
3561
- }
3562
- killChild() {
3563
- const child = this.child;
3564
- this.child = null;
3565
- if (child && !child.killed) try {
3566
- child.kill("SIGTERM");
3567
- } catch (err) {
3568
- this.deps.logger.warn("fmp4 fragment child: kill error", {
3569
- tags: { deviceId: this.args.deviceId },
3570
- meta: {
3571
- brokerId: this.args.brokerId,
3572
- error: errMsg(err)
3573
- }
3574
- });
3575
- }
3576
- }
3577
- };
3578
- //#endregion
3579
- //#region src/stream-broker/stream-broker/fmp4-fragment-plane.ts
3580
- /**
3581
- * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
3582
- * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
3583
- *
3584
- * ## Why a plane and not a callback
3585
- *
3586
- * The operator's requirement for HKSV was explicit: the live fMP4 source built
3587
- * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
3588
- * CamStack as an additional videoclip source alongside the recorder and the NC
3589
- * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
3590
- * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
3591
- * a second ffmpeg child of the same camera. So this is the same shape the
3592
- * broker's other multi-consumer surfaces already have
3593
- * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
3594
- * one producer.
3595
- *
3596
- * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
3597
- * clip source; both are named here so the seam is not re-invented, and neither
3598
- * is built.
3599
- *
3600
- * ## The init segment is RETAINED
3601
- *
3602
- * A subscriber that attaches mid-stream — the clip consumer joining an already
3603
- * running HKSV session, which is the whole dual-use case — receives the
3604
- * retained `ftyp`+`moov` as its first packet and then live fragments. Without
3605
- * retention its fragments are undecodable and the failure looks like a codec
3606
- * problem.
3607
- *
3608
- * ## A slow subscriber is CLOSED, never silently gapped
3609
- *
3610
- * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
3611
- * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
3612
- * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
3613
- * shows a clip that fails to play with nothing anywhere saying why. So a
3614
- * subscription whose queue overflows is ENDED with a reason, loudly, and the
3615
- * other subscriptions are untouched.
3616
- *
3617
- * ## `isLast`
3618
- *
3619
- * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
3620
- * `isLast` — a generator that finishes without it produces the twelve-second
3621
- * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
3622
- * plane therefore computes it at DELIVERY time: a packet is last when the plane
3623
- * has ended and nothing remains queued behind it. A subscription that ends
3624
- * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
3625
- * the future delegate must not open an HDS stream it cannot feed.
3626
- */
3627
- var DEFAULT_QUEUE_CAPACITY = 4;
3628
- var Fmp4FragmentPlane = class {
3629
- logger;
3630
- subscriptions = /* @__PURE__ */ new Map();
3631
- /** The last init unit seen, handed to every later subscriber. */
3632
- retainedInit = null;
3633
- ended = false;
3634
- constructor(logger) {
3635
- this.logger = logger;
3636
- }
3637
- get subscriberCount() {
3638
- return this.subscriptions.size;
3639
- }
3640
- /** True once {@link end} has been called — no further units are accepted. */
3641
- get isEnded() {
3642
- return this.ended;
3643
- }
3644
- subscribe(input) {
3645
- const sub = {
3646
- id: `fmp4-${randomUUID()}`,
3647
- tag: input.tag,
3648
- subscribedAt: Date.now(),
3649
- capacity: Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY),
3650
- queue: [],
3651
- delivered: 0,
3652
- closedReason: null,
3653
- wake: null,
3654
- iterating: false
3655
- };
3656
- this.subscriptions.set(sub.id, sub);
3657
- if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
3658
- if (this.ended) this.closeSubscription(sub, "ended");
3659
- this.logger?.info("fmp4 plane: subscribed", { meta: {
3660
- subscriptionId: sub.id,
3661
- tag: sub.tag,
3662
- hasRetainedInit: this.retainedInit !== null
3663
- } });
3664
- return this.facade(sub);
3665
- }
3666
- /**
3667
- * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
3668
- * emits exactly one per child, and a second means the child was respawned, in
3669
- * which case the old one describes a stream that no longer exists.
3670
- */
3671
- publish(unit) {
3672
- if (this.ended) return;
3673
- if (unit.kind === "init") this.retainedInit = unit;
3674
- for (const sub of this.subscriptions.values()) {
3675
- if (sub.closedReason !== null) continue;
3676
- this.enqueue(sub, unit);
3677
- }
3674
+ if (!settled) {
3675
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
3676
+ return;
3677
+ }
3678
+ if (!isCurrent()) return;
3679
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
3680
+ this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
3681
+ tags: { deviceId: this.args.deviceId },
3682
+ meta: {
3683
+ sourceId: this.args.sourceId,
3684
+ code,
3685
+ signal,
3686
+ unitsOut: this.unitsOut
3687
+ }
3688
+ });
3689
+ this.args.plane.end("the fragment child exited");
3690
+ this.deps.onChildExit?.(error);
3691
+ });
3692
+ });
3678
3693
  }
3679
3694
  /**
3680
- * The producer stopped. Every subscriber drains what it holds; its final
3681
- * packet carries `isLast`, and its generator then completes.
3695
+ * The byte stream stopped being splittable. Not recoverable the splitter
3696
+ * cannot resynchronise mid-box so the child is a corpse and every consumer
3697
+ * has to be told, loudly, with the reason.
3682
3698
  */
3683
- end(reason = "producer ended") {
3684
- if (this.ended) return;
3685
- this.ended = true;
3686
- this.logger?.info("fmp4 plane: ended", { meta: {
3687
- reason,
3688
- subscribers: this.subscriptions.size
3689
- } });
3690
- for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
3691
- }
3692
- listSubscribers() {
3693
- return [...this.subscriptions.values()].map((s) => ({
3694
- tag: s.tag,
3695
- subscribedAt: s.subscribedAt,
3696
- delivered: s.delivered,
3697
- closedReason: s.closedReason
3698
- }));
3699
- }
3700
- /** End and forget everything. Idempotent. */
3701
- dispose() {
3702
- this.end("disposed");
3703
- this.subscriptions.clear();
3704
- this.retainedInit = null;
3705
- }
3706
- enqueue(sub, unit) {
3707
- if (sub.queue.length >= sub.capacity) {
3708
- this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
3709
- subscriptionId: sub.id,
3710
- tag: sub.tag,
3711
- capacity: sub.capacity,
3712
- delivered: sub.delivered
3713
- } });
3714
- this.closeSubscription(sub, "slow-consumer");
3715
- return;
3716
- }
3717
- sub.queue.push({
3718
- kind: unit.kind,
3719
- data: unit.data,
3720
- sequence: unit.sequence,
3721
- isLast: false
3722
- });
3723
- this.wake(sub);
3724
- }
3725
- closeSubscription(sub, reason) {
3726
- if (sub.closedReason !== null) return;
3727
- sub.closedReason = reason;
3728
- if (reason === "slow-consumer") sub.queue.length = 0;
3729
- this.wake(sub);
3730
- }
3731
- wake(sub) {
3732
- const resume = sub.wake;
3733
- sub.wake = null;
3734
- resume?.();
3735
- }
3736
- facade(sub) {
3737
- const plane = this;
3738
- return {
3739
- id: sub.id,
3740
- tag: sub.tag,
3741
- get delivered() {
3742
- return sub.delivered;
3743
- },
3744
- get closedReason() {
3745
- return sub.closedReason;
3746
- },
3747
- packets: () => plane.iterate(sub),
3748
- release: () => {
3749
- plane.closeSubscription(sub, "released");
3750
- plane.subscriptions.delete(sub.id);
3699
+ onFault(reason, wasLive, current, settle) {
3700
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
3701
+ this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
3702
+ tags: { deviceId: this.args.deviceId },
3703
+ meta: {
3704
+ sourceId: this.args.sourceId,
3705
+ unitsOut: this.unitsOut,
3706
+ interstitial: this.splitter.discardedInterstitialTypes,
3707
+ reason
3751
3708
  }
3752
- };
3709
+ });
3710
+ this.killChild();
3711
+ settle(error);
3712
+ if (wasLive && current) {
3713
+ this.args.plane.end("the fragment child produced unsplittable output");
3714
+ this.deps.onChildExit?.(error);
3715
+ }
3753
3716
  }
3754
- async *iterate(sub) {
3755
- if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
3756
- sub.iterating = true;
3757
- for (;;) {
3758
- const next = sub.queue.shift();
3759
- if (next === void 0) {
3760
- if (sub.closedReason !== null) return;
3761
- await new Promise((resolve) => {
3762
- sub.wake = resolve;
3763
- });
3764
- continue;
3765
- }
3766
- const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
3767
- sub.delivered += 1;
3768
- yield {
3769
- ...next,
3770
- isLast
3771
- };
3772
- if (isLast) return;
3717
+ killChild() {
3718
+ const child = this.child;
3719
+ this.child = null;
3720
+ if (child && !child.killed) try {
3721
+ child.kill("SIGTERM");
3722
+ } catch (err) {
3723
+ this.deps.logger.warn("fmp4 fragment child: kill error", {
3724
+ tags: { deviceId: this.args.deviceId },
3725
+ meta: {
3726
+ sourceId: this.args.sourceId,
3727
+ error: errMsg(err)
3728
+ }
3729
+ });
3773
3730
  }
3774
3731
  }
3775
3732
  };
@@ -4599,7 +4556,7 @@ var EgressTranscodeManager = class {
4599
4556
  *
4600
4557
  * The child owns the software retry and the liveness reporting itself, in the
4601
4558
  * same shape as the other two transports; this wrapper is the adapter, not
4602
- * the logic. See `egress-fragment-child.ts`.
4559
+ * the logic. See the primitive's `ffmpeg/fmp4-fragment-child.ts`.
4603
4560
  */
4604
4561
  createFragmentChild(spec, fragments) {
4605
4562
  const child = new Fmp4FragmentChild({
@@ -4609,7 +4566,7 @@ var EgressTranscodeManager = class {
4609
4566
  ...this.opts.fragmentFirstUnitTimeoutMs === void 0 ? {} : { firstUnitTimeoutMs: this.opts.fragmentFirstUnitTimeoutMs },
4610
4567
  ...spec.onDied ? { onChildExit: spec.onDied } : {}
4611
4568
  }, {
4612
- brokerId: spec.brokerId,
4569
+ sourceId: spec.brokerId,
4613
4570
  deviceId: spec.deviceId,
4614
4571
  fragmentMs: fragments.fragmentMs,
4615
4572
  invocation: spec.invocation,
@@ -9858,6 +9815,29 @@ var StreamBroker = class StreamBroker {
9858
9815
  * right model based on per-camera codec + sample rate.
9859
9816
  */
9860
9817
  audioTrackInfo = null;
9818
+ /**
9819
+ * BROKER-SIDE AUDIO MUTE (D83) — operator policy, not a stream fact.
9820
+ *
9821
+ * While true this broker distributes NO audio on any plane it serves. It is
9822
+ * applied at the three points audio ENTERS the broker (`onAudioRtp`,
9823
+ * `pushEncodedPacket`) and re-checked at the single point it LEAVES
9824
+ * (`fanOutEncoded`), so a packet already inside a decode session when the
9825
+ * mute lands cannot escape.
9826
+ *
9827
+ * Deliberately NOT cleared by `resetStreamState` / a re-dial: it is device
9828
+ * policy that outlives any one connection. `StreamBrokerManager` re-applies
9829
+ * it to every freshly created broker for the same reason.
9830
+ */
9831
+ audioMuted = false;
9832
+ /** Audio units dropped by the mute since the current dial. Surfaced in stats. */
9833
+ audioMutedDropped = 0;
9834
+ /**
9835
+ * One-shot per dial: the mute's drop is announced the FIRST time it actually
9836
+ * drops something, not once per packet. A broker created while already muted
9837
+ * has no transition to log, and a silent drop reads as "the camera has no
9838
+ * microphone" — which is the confusion this whole row exists to remove.
9839
+ */
9840
+ audioMutedDropLogged = false;
9861
9841
  reconnectTimer;
9862
9842
  placeholderTimer;
9863
9843
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
@@ -10409,6 +10389,69 @@ var StreamBroker = class StreamBroker {
10409
10389
  this.logger?.warn("source-restart observer threw", { meta: { error: errMsg(err) } });
10410
10390
  }
10411
10391
  }
10392
+ /**
10393
+ * Turn this broker's audio plane off (or back on).
10394
+ *
10395
+ * Idempotent: setting the value it already holds does nothing at all — in
10396
+ * particular it does NOT churn the RTSP sessions, so the manager can call
10397
+ * this on every broker creation and every reconcile pass without cost.
10398
+ *
10399
+ * A real transition closes the restreamer's audio-bearing pull sessions.
10400
+ * That is not incidental: the recorder pulls this restreamer with `-map 0`,
10401
+ * so its ffmpeg maps whatever the SDP declared at CONNECT time. Dropping the
10402
+ * RTP while leaving an `m=audio` in its SDP would leave it waiting on an
10403
+ * audio stream that never delivers (the mp4 muxer holds video up to
10404
+ * `max_interleave_delta`). Re-pulling is what makes the recording honestly
10405
+ * audio-less rather than audio-declared-and-starved. The recorder's
10406
+ * `SegmentWriter` respawns on exit with bounded backoff, so the cost is one
10407
+ * segment boundary per flip.
10408
+ */
10409
+ setAudioMuted(muted) {
10410
+ if (this.audioMuted === muted) return;
10411
+ this.audioMuted = muted;
10412
+ this.audioMutedDropped = 0;
10413
+ this.audioMutedDropLogged = false;
10414
+ const closedPulls = this.rtspRestreamer.setAudioMuted(muted);
10415
+ this.logger?.info(muted ? "audio plane MUTED broker-side — live and recording go silent" : "audio plane UNMUTED broker-side — audio distribution resumes", {
10416
+ tags: { deviceId: this.numericDeviceId },
10417
+ meta: {
10418
+ brokerId: this.deviceId,
10419
+ audioCodec: this.audioCodec,
10420
+ hasAudioTrack: this.audioTrackInfo !== null,
10421
+ closedPulls
10422
+ }
10423
+ });
10424
+ }
10425
+ /** The broker's own audio-plane policy. `true` = distributing no audio. */
10426
+ isAudioMuted() {
10427
+ return this.audioMuted;
10428
+ }
10429
+ /**
10430
+ * True when the current connection is muxing audio at all — the OBSERVED
10431
+ * half of the D83 audio-plane reconcile. Read off the SDP/track snapshot
10432
+ * rather than a packet counter so a camera that negotiated an audio track
10433
+ * and has simply been quiet does not read as a missing mux.
10434
+ */
10435
+ hasAudioTrack() {
10436
+ return this.audioTrackInfo !== null;
10437
+ }
10438
+ /**
10439
+ * Account for one audio unit the mute threw away, announcing the FIRST one
10440
+ * per dial. Never per-packet: at 50 pps a line each would be its own outage.
10441
+ */
10442
+ dropMutedAudio(where) {
10443
+ this.audioMutedDropped += 1;
10444
+ if (this.audioMutedDropLogged) return;
10445
+ this.audioMutedDropLogged = true;
10446
+ this.logger?.info("audio muted broker-side — dropping this camera’s audio plane", {
10447
+ tags: { deviceId: this.numericDeviceId },
10448
+ meta: {
10449
+ brokerId: this.deviceId,
10450
+ where,
10451
+ audioCodec: this.audioCodec
10452
+ }
10453
+ });
10454
+ }
10412
10455
  /** Set whether pre-buffer counts as demand. When false and no other consumers, the broker suspends. */
10413
10456
  setPreBufferEnabled(enabled) {
10414
10457
  this._preBufferEnabled = enabled;
@@ -10681,6 +10724,10 @@ var StreamBroker = class StreamBroker {
10681
10724
  }
10682
10725
  this.resetPushStallTimer();
10683
10726
  }
10727
+ if (packet.type === "audio" && this.audioMuted) {
10728
+ this.dropMutedAudio("push-encoded");
10729
+ return;
10730
+ }
10684
10731
  if (packet.type === "audio" && this.source?.type === "push" && this.audioCodecSession) {
10685
10732
  const codec = packet.codec.toLowerCase();
10686
10733
  if (codec !== "pcmu" && codec !== "pcma") {
@@ -10961,6 +11008,10 @@ var StreamBroker = class StreamBroker {
10961
11008
  * safe one.
10962
11009
  */
10963
11010
  fanOutEncoded(packet) {
11011
+ if (packet.type === "audio" && this.audioMuted) {
11012
+ this.dropMutedAudio("encoded-fanout");
11013
+ return;
11014
+ }
10964
11015
  for (const [cb, entry] of this.encodedCallbacks) try {
10965
11016
  cb(packet);
10966
11017
  entry.packetsDelivered += 1;
@@ -11239,7 +11290,9 @@ var StreamBroker = class StreamBroker {
11239
11290
  preBufferMs: this.preBuffer.getBufferedDurationMs(),
11240
11291
  preBufferPackets: this.preBuffer.getPacketCount(),
11241
11292
  decoderNodeId: this.frameHandlePlane?.decoderNodeIds()[0] ?? null,
11242
- audio: this.audioTrackInfo ?? null
11293
+ audio: this.audioTrackInfo ?? null,
11294
+ audioMuted: this.audioMuted,
11295
+ audioMutedDropped: this.audioMutedDropped
11243
11296
  };
11244
11297
  }
11245
11298
  /**
@@ -11674,6 +11727,10 @@ var StreamBroker = class StreamBroker {
11674
11727
  onAudioRtp: (rtpData) => {
11675
11728
  if (this.stopping) return;
11676
11729
  this.kickRtpActivityWatchdog();
11730
+ if (this.audioMuted) {
11731
+ this.dropMutedAudio("rtsp-audio-rtp");
11732
+ return;
11733
+ }
11677
11734
  this.audioRtpSeen++;
11678
11735
  if (this.audioRtpSeen === 1 || this.audioRtpSeen === 50) this.logger?.info("audio: rtp packet received", { meta: {
11679
11736
  seq: this.audioRtpSeen,
@@ -12336,6 +12393,79 @@ function hexToBytes(hex) {
12336
12393
  }
12337
12394
  return out;
12338
12395
  }
12396
+ /**
12397
+ * Minimum gap between two re-dials of the same device. A re-dial costs a
12398
+ * ~2 s video interruption on every source of the camera; at the 30 s
12399
+ * reconcile cadence, an un-cooled loop against a camera whose audio genuinely
12400
+ * cannot come back would be a permanent stutter.
12401
+ */
12402
+ var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
12403
+ var EMPTY_AUDIO_PLANE_STATE = {
12404
+ disagreements: 0,
12405
+ redials: 0,
12406
+ lastRedialMs: 0,
12407
+ gaveUp: false
12408
+ };
12409
+ /**
12410
+ * Decide what one pass should do. Pure: no clock, no I/O, no broker.
12411
+ *
12412
+ * `nowMs` is passed in rather than read so the cooldown is testable without
12413
+ * waiting five minutes for it.
12414
+ */
12415
+ function decideAudioPlaneAction(observation, state, nowMs) {
12416
+ switch (observation.kind) {
12417
+ case "broker-muted":
12418
+ case "no-live-source":
12419
+ case "audio-present":
12420
+ case "declared-off": return {
12421
+ action: { kind: "none" },
12422
+ nextState: EMPTY_AUDIO_PLANE_STATE
12423
+ };
12424
+ case "read-failed":
12425
+ case "audio-unknown": return {
12426
+ action: { kind: "none" },
12427
+ nextState: state
12428
+ };
12429
+ case "declared-on": {
12430
+ if (state.gaveUp) return {
12431
+ action: { kind: "none" },
12432
+ nextState: state
12433
+ };
12434
+ const disagreements = state.disagreements + 1;
12435
+ if (disagreements < 2) return {
12436
+ action: { kind: "await-second-pass" },
12437
+ nextState: {
12438
+ ...state,
12439
+ disagreements
12440
+ }
12441
+ };
12442
+ if (state.redials >= 3) return {
12443
+ action: { kind: "give-up" },
12444
+ nextState: {
12445
+ ...state,
12446
+ disagreements,
12447
+ gaveUp: true
12448
+ }
12449
+ };
12450
+ if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
12451
+ action: { kind: "cooldown" },
12452
+ nextState: {
12453
+ ...state,
12454
+ disagreements: 2
12455
+ }
12456
+ };
12457
+ return {
12458
+ action: { kind: "redial" },
12459
+ nextState: {
12460
+ disagreements: 0,
12461
+ redials: state.redials + 1,
12462
+ lastRedialMs: nowMs,
12463
+ gaveUp: false
12464
+ }
12465
+ };
12466
+ }
12467
+ }
12468
+ }
12339
12469
  //#endregion
12340
12470
  //#region src/stream-broker/stream-broker/transcode-pipeline.ts
12341
12471
  /**
@@ -12820,12 +12950,31 @@ var DeviceOverrideSchema = object({
12820
12950
  preBuffer: record(string(), StreamPreBufferSchema).optional(),
12821
12951
  joinBurstMaxSpanMs: record(string(), number()).optional(),
12822
12952
  streamingDebug: boolean().optional(),
12953
+ audioMuted: boolean().optional(),
12823
12954
  webrtcDebug: boolean().optional(),
12824
12955
  derivedStreams: record(string(), DerivedStreamSchema).optional(),
12825
12956
  hwaccel: record(string(), FfmpegHwAccelSettingSchema).optional(),
12826
12957
  sourceParser: record(string(), SourceParserSettingSchema).optional(),
12827
12958
  sourceParserArgs: record(string(), SourceParserArgsEntrySchema).optional()
12828
12959
  });
12960
+ /**
12961
+ * Read `audioEnabled` off a `privacyMask.getStatus` payload (D83 reconcile).
12962
+ *
12963
+ * `getStatus` is the one cap method whose output is the generic device-status
12964
+ * shape rather than the cap's own schema, so it arrives untyped — this is the
12965
+ * documented type boundary, and a guard rather than a cast.
12966
+ *
12967
+ * `null` means UNKNOWN, and unknown must never become "audio is on": that is
12968
+ * the read that would re-dial a healthy stream on the strength of a payload
12969
+ * we could not parse. The camera says `null` itself when it has no microphone
12970
+ * control (`supportsAudioMute === false`), which lands in the same bucket for
12971
+ * the same reason.
12972
+ */
12973
+ function readDeclaredAudioEnabled(v) {
12974
+ if (v === null || typeof v !== "object") return null;
12975
+ const raw = { ...v }["audioEnabled"];
12976
+ return typeof raw === "boolean" ? raw : null;
12977
+ }
12829
12978
  /** Whole-blob schema for the persisted `deviceOverrides` store key:
12830
12979
  * numeric-string deviceId → DeviceOverride (includes `derivedStreams`). */
12831
12980
  var DeviceOverridesMapSchema = record(string(), DeviceOverrideSchema);
@@ -12949,6 +13098,11 @@ var StreamBrokerManager = class StreamBrokerManager {
12949
13098
  /** Per-device overrides (pre-buffer + legacy). */
12950
13099
  deviceOverrides = /* @__PURE__ */ new Map();
12951
13100
  /**
13101
+ * D83 audio-plane reconcile memory, per device. Absent = agreeing; entries
13102
+ * are deleted the moment a pass agrees, so a healthy fleet holds none.
13103
+ */
13104
+ audioPlaneState = /* @__PURE__ */ new Map();
13105
+ /**
12952
13106
  * `deviceId:camStreamId` keys for which the `native-udp` → `native-tcp`
12953
13107
  * fallback warning has already been logged, so the warn fires once per stream
12954
13108
  * rather than on every source (re)build / dial.
@@ -14296,7 +14450,96 @@ var StreamBrokerManager = class StreamBrokerManager {
14296
14450
  this.disabledDevices.add(d.id);
14297
14451
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
14298
14452
  } else this.disabledDevices.delete(d.id);
14299
- await Promise.allSettled(cameras.filter((d) => !d.disabled).map((d) => this.reconcileDeviceCatalog(d.id)));
14453
+ const live = cameras.filter((d) => !d.disabled);
14454
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id)));
14455
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
14456
+ const liveIds = new Set(live.map((d) => d.id));
14457
+ for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
14458
+ }
14459
+ /**
14460
+ * ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
14461
+ * for the decision rules; this half only gathers and acts.
14462
+ *
14463
+ * Every branch that changes nothing still says so at debug — the whole
14464
+ * failure this repairs was invisible, and a reconcile whose "I looked and
14465
+ * everything was fine" is unobservable is a reconcile nobody can trust
14466
+ * after the next incident.
14467
+ */
14468
+ async reconcileDeviceAudioPlane(deviceId) {
14469
+ const observation = await this.observeAudioPlane(deviceId);
14470
+ const { action, nextState } = decideAudioPlaneAction(observation, this.audioPlaneState.get(deviceId) ?? EMPTY_AUDIO_PLANE_STATE, Date.now());
14471
+ if (nextState === EMPTY_AUDIO_PLANE_STATE) this.audioPlaneState.delete(deviceId);
14472
+ else this.audioPlaneState.set(deviceId, nextState);
14473
+ const meta = {
14474
+ observed: observation.kind,
14475
+ disagreements: nextState.disagreements,
14476
+ redials: nextState.redials
14477
+ };
14478
+ switch (action.kind) {
14479
+ case "none": return;
14480
+ case "await-second-pass":
14481
+ this.logger.info("audio-plane reconcile: camera declares audio but no broker carries a track — confirming on the next pass", {
14482
+ tags: { deviceId },
14483
+ meta
14484
+ });
14485
+ return;
14486
+ case "cooldown":
14487
+ this.logger.debug("audio-plane reconcile: disagreement held — re-dial cooling down", {
14488
+ tags: { deviceId },
14489
+ meta: {
14490
+ ...meta,
14491
+ cooldownMs: AUDIO_PLANE_REDIAL_COOLDOWN_MS
14492
+ }
14493
+ });
14494
+ return;
14495
+ case "give-up":
14496
+ this.logger.warn("audio-plane reconcile: giving up — the camera declares audio and re-dialing has not brought a track back", {
14497
+ tags: { deviceId },
14498
+ meta: {
14499
+ ...meta,
14500
+ maxRedials: 3
14501
+ }
14502
+ });
14503
+ return;
14504
+ case "redial":
14505
+ this.logger.info("audio-plane reconcile: audio declared but absent on every source — re-dialing", {
14506
+ tags: { deviceId },
14507
+ meta
14508
+ });
14509
+ this.redialDeviceSources(deviceId, "audio-plane-reconcile");
14510
+ return;
14511
+ }
14512
+ }
14513
+ /**
14514
+ * The gather half. Cheap questions first: the expensive device read happens
14515
+ * ONLY when the free in-memory answer already looks wrong.
14516
+ */
14517
+ async observeAudioPlane(deviceId) {
14518
+ if (this.deviceOverrides.get(deviceId)?.audioMuted === true) return { kind: "broker-muted" };
14519
+ const streams = this.cameraStreams.get(deviceId);
14520
+ if (!streams) return { kind: "no-live-source" };
14521
+ const sources = [];
14522
+ for (const [camStreamId, entry] of streams) {
14523
+ if (entry.kind === "derived") continue;
14524
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
14525
+ if (broker && broker.status === "streaming") sources.push(broker);
14526
+ }
14527
+ if (sources.length === 0) return { kind: "no-live-source" };
14528
+ if (sources.some((b) => b.hasAudioTrack())) return { kind: "audio-present" };
14529
+ if (!this.api) return { kind: "read-failed" };
14530
+ let status;
14531
+ try {
14532
+ status = await this.api.privacyMask.getStatus.query({ deviceId });
14533
+ } catch (err) {
14534
+ this.logger.debug("audio-plane reconcile: device audio read failed — changing nothing", {
14535
+ tags: { deviceId },
14536
+ meta: { error: errMsg(err) }
14537
+ });
14538
+ return { kind: "read-failed" };
14539
+ }
14540
+ const declared = readDeclaredAudioEnabled(status);
14541
+ if (declared === null) return { kind: "audio-unknown" };
14542
+ return declared ? { kind: "declared-on" } : { kind: "declared-off" };
14300
14543
  }
14301
14544
  /** Release every live broker of a disabled device (idempotent — later poll
14302
14545
  * ticks find none and no-op). `logTransition` marks the disable EDGE. */
@@ -16255,6 +16498,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16255
16498
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
16256
16499
  broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
16257
16500
  this.applyPreBufferConfig(broker, deviceId, camStreamId);
16501
+ this.applyAudioMuteConfig(broker, deviceId);
16258
16502
  this.applyJoinBurstBound(broker, deviceId, camStreamId);
16259
16503
  this.applyClipRetention(deviceId);
16260
16504
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
@@ -16450,6 +16694,72 @@ var StreamBrokerManager = class StreamBrokerManager {
16450
16694
  broker.setPreBufferEnabled(defaultWarm);
16451
16695
  broker.setPreBufferDuration(defaultWarm ? this.defaultPreBufferSec : 0);
16452
16696
  }
16697
+ /** Push the persisted mute onto a broker. Idempotent — safe on every pass. */
16698
+ applyAudioMuteConfig(broker, deviceId) {
16699
+ broker.setAudioMuted(this.deviceOverrides.get(deviceId)?.audioMuted === true);
16700
+ }
16701
+ /** Every live broker of a device, derived and source alike. */
16702
+ brokersOfDevice(deviceId) {
16703
+ const streams = this.cameraStreams.get(deviceId);
16704
+ if (!streams) return [];
16705
+ const found = [];
16706
+ for (const camStreamId of streams.keys()) {
16707
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
16708
+ if (broker) found.push(broker);
16709
+ }
16710
+ return found;
16711
+ }
16712
+ /**
16713
+ * `streamBroker.getDeviceAudioMute` — read the device's audio-plane policy.
16714
+ *
16715
+ * The STORE is the answer, not the brokers: the policy applies to brokers
16716
+ * that do not exist yet, so a camera with nothing dialled is still muted.
16717
+ * `appliedBrokers` reports how much of it is presently in force, and is
16718
+ * diagnostic only — a switch that read it as the state would flip itself
16719
+ * off every time the camera went idle.
16720
+ */
16721
+ getDeviceAudioMute(input) {
16722
+ return {
16723
+ muted: this.deviceOverrides.get(input.deviceId)?.audioMuted === true,
16724
+ appliedBrokers: this.brokersOfDevice(input.deviceId).filter((b) => b.isAudioMuted()).length
16725
+ };
16726
+ }
16727
+ /**
16728
+ * Set the device's audio-plane policy: persist it, then apply it to every
16729
+ * live broker — including DERIVED ones, whose restreamer would otherwise
16730
+ * keep serving an `m=audio` grafted from its transcode egress.
16731
+ *
16732
+ * Persist BEFORE applying. If the process dies between the two, the next
16733
+ * boot re-applies from the store and the camera is silent as asked; the
16734
+ * other order loses the operator's decision and the camera is audible again
16735
+ * with the switch still reading off.
16736
+ */
16737
+ async setDeviceAudioMute(input) {
16738
+ const { deviceId, muted } = input;
16739
+ const prior = this.deviceOverrides.get(deviceId)?.audioMuted === true;
16740
+ const next = {
16741
+ ...this.deviceOverrides.get(deviceId),
16742
+ audioMuted: muted
16743
+ };
16744
+ this.deviceOverrides.set(deviceId, next);
16745
+ this.deviceOverridePersister?.(this.deviceOverrides);
16746
+ const brokers = this.brokersOfDevice(deviceId);
16747
+ for (const broker of brokers) broker.setAudioMuted(muted);
16748
+ if (prior !== muted) {
16749
+ this.logger.info("broker audio mute set", {
16750
+ tags: { deviceId },
16751
+ meta: {
16752
+ muted,
16753
+ brokers: brokers.length
16754
+ }
16755
+ });
16756
+ this.audioPlaneState.delete(deviceId);
16757
+ }
16758
+ return Promise.resolve({
16759
+ muted,
16760
+ appliedBrokers: brokers.filter((b) => b.isAudioMuted()).length
16761
+ });
16762
+ }
16453
16763
  async fanOutRegistration(_deviceId, brokerId, _cam, broker) {
16454
16764
  if (this.webrtcServer) this.webrtcServer.registerBroker(brokerId, broker);
16455
16765
  }