@camstack/addon-pipeline-orchestrator 1.2.221 → 1.2.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -5363,7 +5363,7 @@ var ZodIssueCode = {
5363
5363
  var ZodFirstPartyTypeKind;
5364
5364
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5365
5365
  //#endregion
5366
- //#region ../types/dist/sleep-GU_us3DG.mjs
5366
+ //#region ../types/dist/sleep-CiR4z7r9.mjs
5367
5367
  /**
5368
5368
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5369
5369
  * window to float samples (D455).
@@ -6521,6 +6521,23 @@ var CamProfileSchema = _enum([
6521
6521
  "mid",
6522
6522
  "low"
6523
6523
  ]);
6524
+ /** Canonical ordering. Hard-coded; never sorted. */
6525
+ var CAM_PROFILE_ORDER = [
6526
+ "high",
6527
+ "mid",
6528
+ "low"
6529
+ ];
6530
+ /**
6531
+ * Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
6532
+ *
6533
+ * `auto` is a REAL value, not an absent one — it means "resolve it by the rule
6534
+ * that consumer owns". It lives beside `CamProfile` because a rung is the only
6535
+ * operator-facing way to name a stream: a cam-stream id is broker-internal and
6536
+ * moves when a slot is reassigned, so a stored id silently stops meaning what
6537
+ * the operator picked. Today's one user is the audio analyser, whose rule is
6538
+ * "the lowest AVAILABLE native rung" (D537).
6539
+ */
6540
+ var AudioStreamPickSchema = union([CamProfileSchema, literal("auto")]);
6524
6541
  var CamStreamKindSchema = _enum([
6525
6542
  "pull-rtsp",
6526
6543
  "pull-rtmp",
@@ -21004,6 +21021,7 @@ var MediaFileKindEnum = _enum([
21004
21021
  "keyFrameSmall",
21005
21022
  "thumbnailSmall",
21006
21023
  "confirmationCrop",
21024
+ "debugCrop",
21007
21025
  "sceneFrame"
21008
21026
  ]);
21009
21027
  /**
@@ -22668,6 +22686,18 @@ var RunnerCameraConfigSchema = object({
22668
22686
  motionStreamId: string(),
22669
22687
  detectionStreamId: string(),
22670
22688
  /**
22689
+ * Which rung this camera's audio analysis listens on — `auto` (the lowest
22690
+ * AVAILABLE native rung) or an explicit one.
22691
+ *
22692
+ * Not consumed by the runner: carried here so it travels with the camera's
22693
+ * config the way `audioMotionWindowMs` does, and so the orchestrator's
22694
+ * watchdog resolves the same stream the audio subscription did. OPTIONAL
22695
+ * with no default — absent means `auto`, which is what every camera written
22696
+ * before this field had, and an absent optional adds nothing to the
22697
+ * generated cap-input defaults (D537).
22698
+ */
22699
+ audioStreamPick: AudioStreamPickSchema.optional(),
22700
+ /**
22671
22701
  * Motion sources that can activate the pipeline. At least one must
22672
22702
  * be present. Multiple sources OR-ed: any one firing arms the
22673
22703
  * cooldown timer and transitions watching → active. The same
@@ -25690,6 +25720,104 @@ var WebrtcStreamChoiceSchema = object({
25690
25720
  /** Bitrate in kbps (null for adaptive). */
25691
25721
  bitrateKbps: number().nullable()
25692
25722
  });
25723
+ /**
25724
+ * The last move the adaptive ladder made on this session, and the numbers it
25725
+ * rested on. Mirrors `LadderDecision` in the stream-broker's
25726
+ * `adaptive-controller.ts` — the log line's evidence, made readable.
25727
+ *
25728
+ * Nullability is load-bearing: `lossRatio: null` means the loss tracker had
25729
+ * no window to report, which is NOT a link with zero loss.
25730
+ */
25731
+ var WebrtcLadderDecisionSchema = object({
25732
+ /** `loss` — RTCP / client-reported packet loss; `stall` — the picture
25733
+ * stopped moving; `healthy` — a promotion. */
25734
+ cause: _enum([
25735
+ "loss",
25736
+ "stall",
25737
+ "healthy"
25738
+ ]),
25739
+ /** The rung the ladder moved TO. Null for a move that changed no rung
25740
+ * (an MTU reduction, a forced transcode-down at the bottom). */
25741
+ tier: CamProfileSchema.nullable(),
25742
+ /** Whether that move forced the egress transcode-down rung. */
25743
+ forcedTranscodeDown: boolean(),
25744
+ /** Loss ratio over the tracker's window at the moment of the decision. */
25745
+ lossRatio: number().nullable(),
25746
+ /** The client's reported downlink at that moment, in kbps. */
25747
+ clientKbps: number().nullable(),
25748
+ /** How long the stall detector had been seeing nothing arrive. */
25749
+ stalledForMs: number().nullable(),
25750
+ /** Whether the broker WAS sending while that was measured — the half that
25751
+ * separates "the camera stopped" from "the link stopped". */
25752
+ serverSending: boolean().nullable(),
25753
+ /** Epoch ms. The overlay prints its age; a decision from 40 minutes ago
25754
+ * must not read like one from this second. */
25755
+ at: number()
25756
+ });
25757
+ /** Per-tier count of MEASURED downgrades this viewer session has suffered. */
25758
+ var WebrtcTierFailureSchema = object({
25759
+ tier: CamProfileSchema,
25760
+ failures: number().int().nonnegative()
25761
+ });
25762
+ /**
25763
+ * What the SERVER decided for one WebRTC session — the half the client cannot
25764
+ * measure. See `getSessionDebug`.
25765
+ */
25766
+ var WebrtcSessionDebugSchema = object({
25767
+ sessionId: string(),
25768
+ /** Whether the session still exists here. Everything below is null when
25769
+ * it does not — a dead session has no measurements, not zero ones. */
25770
+ alive: boolean(),
25771
+ /** Epoch ms this snapshot was taken, so the overlay can show its age. */
25772
+ observedAt: number(),
25773
+ /** The broker actually feeding this session (`<deviceId>/<camStreamId>`). */
25774
+ brokerId: string().nullable(),
25775
+ camStreamId: string().nullable(),
25776
+ /** `derived` = this hub BUILT the rung being served (a transcode/scale of
25777
+ * another stream); `native` = the camera's own encode. */
25778
+ sourceKind: _enum([
25779
+ "native",
25780
+ "derived",
25781
+ "unknown"
25782
+ ]),
25783
+ /** Whether the ladder is running at all (an `adaptive` target). */
25784
+ adaptive: boolean(),
25785
+ /** The rung `auto` ACTUALLY resolved to right now. Null when the broker
25786
+ * is bound to no profile slot (a manually-activated stream). */
25787
+ servedTier: CamProfileSchema.nullable(),
25788
+ /** Which node ingests the source. Null when unresolved. */
25789
+ ingestNodeId: string().nullable(),
25790
+ /** Whether that node is a remote one (the media crosses a node boundary
25791
+ * before it reaches this session). Null when unresolved. */
25792
+ ingestIsRemote: boolean().nullable(),
25793
+ /** Codec off the source, and the one negotiated with this client. */
25794
+ sourceCodec: string().nullable(),
25795
+ negotiatedCodec: string().nullable(),
25796
+ /** Whether the EGRESS leg re-encodes for this client or forwards the
25797
+ * source bytes untouched. */
25798
+ egressLeg: _enum([
25799
+ "copy",
25800
+ "transcode",
25801
+ "unknown"
25802
+ ]),
25803
+ /** What the broker itself reports about the rung it is serving. */
25804
+ brokerStatus: string().nullable(),
25805
+ brokerCodec: string().nullable(),
25806
+ brokerBitrateKbps: number().nullable(),
25807
+ brokerInputFps: number().nullable(),
25808
+ brokerUptimeMs: number().nullable(),
25809
+ /** RTP packets this session has sent — the server's own "am I sending?". */
25810
+ rtpPacketsSent: number().nullable(),
25811
+ /** D42: the track may be carrying the recorded feeder, not live. The ladder
25812
+ * is suspended there, so a frozen picture in `recorded` mode means
25813
+ * something entirely different. */
25814
+ trackMode: _enum(["live", "recorded"]).nullable(),
25815
+ lastDecision: WebrtcLadderDecisionSchema.nullable(),
25816
+ tierFailures: array(WebrtcTierFailureSchema),
25817
+ /** Successful promotions in this viewer session (drives the progressive
25818
+ * healthy timer). Null when no ladder is running. */
25819
+ upgradeCount: number().nullable()
25820
+ });
25693
25821
  DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(WebrtcStreamChoiceSchema)), method(object({
25694
25822
  deviceId: number().int().nonnegative(),
25695
25823
  target: WebrtcStreamTargetSchema,
@@ -25854,7 +25982,10 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
25854
25982
  * the same answer as "it ended": either way nothing is holding it up.
25855
25983
  */
25856
25984
  alive: boolean()
25857
- }));
25985
+ })), method(object({
25986
+ deviceId: number().int().nonnegative(),
25987
+ sessionId: string()
25988
+ }), WebrtcSessionDebugSchema, { kind: "query" });
25858
25989
  object({
25859
25990
  /** All accessory children of the parent. */
25860
25991
  childDeviceIds: array(number()).readonly(),
@@ -28942,16 +29073,37 @@ var GridProfileOfferSchema = object({
28942
29073
  ]),
28943
29074
  offered: boolean(),
28944
29075
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
28945
- missingSources: array(number().int().positive())
29076
+ missingSources: array(number().int().positive()),
29077
+ /**
29078
+ * The canvas this profile composes onto, `WxH`, or empty when it is not
29079
+ * offered. DERIVED from the cells and the sources' own size at this profile —
29080
+ * it is reported because nothing else in the system would ever say what the
29081
+ * grid came out as, and because it is the number an operator would otherwise
29082
+ * expect to type.
29083
+ */
29084
+ canvas: string(),
29085
+ /**
29086
+ * Whether this profile is PUBLISHED, of the ones the grid could serve.
29087
+ *
29088
+ * A grid's `high` is composed of its sources' `high`, so on a 4K fleet it is
29089
+ * a 4K canvas built from 4K decodes — something to opt into, not something a
29090
+ * viewer's adaptive should be handed by climbing to the top rung it can see.
29091
+ * Default is `mid` + `low`.
29092
+ */
29093
+ published: boolean()
28946
29094
  });
28947
29095
  var GridLayoutViewSchema = object({
28948
29096
  /** The persisted grid row this camera was declared from. */
28949
29097
  instanceId: string(),
28950
29098
  deviceId: number().int().nonnegative(),
28951
29099
  name: string(),
28952
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
28953
- width: number().int(),
28954
- height: number().int(),
29100
+ /**
29101
+ * NO canvas size. A grid's resolution is not authored: each profile derives
29102
+ * its own from the cells and its sources' dimensions. The two numbers that
29103
+ * used to be here were a text field that silently decided both how much the
29104
+ * composite cost and how sharp it was — see `profiles[].canvas` for what it
29105
+ * came out as.
29106
+ */
28955
29107
  fps: number().int(),
28956
29108
  cells: array(GridLayoutCellSchema),
28957
29109
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -28960,9 +29112,13 @@ var GridLayoutViewSchema = object({
28960
29112
  var GridLayoutPatchSchema = object({
28961
29113
  deviceId: number().int().nonnegative(),
28962
29114
  name: string().min(1).max(160).optional(),
28963
- width: number().int().min(160).max(7680).optional(),
28964
- height: number().int().min(120).max(4320).optional(),
28965
29115
  fps: number().int().min(1).max(60).optional(),
29116
+ /** Which profiles to publish. See `GridProfileOffer.published`. */
29117
+ publishedProfiles: array(_enum([
29118
+ "high",
29119
+ "mid",
29120
+ "low"
29121
+ ])).max(3).optional(),
28966
29122
  /**
28967
29123
  * The whole cell list at once. A per-cell patch would need an ordering the
28968
29124
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -39631,6 +39787,12 @@ Object.freeze({
39631
39787
  addonId: null,
39632
39788
  access: "view"
39633
39789
  },
39790
+ "webrtcSession.getSessionDebug": {
39791
+ capName: "webrtc-session",
39792
+ capScope: "device",
39793
+ addonId: null,
39794
+ access: "view"
39795
+ },
39634
39796
  "webrtcSession.getSessionState": {
39635
39797
  capName: "webrtc-session",
39636
39798
  capScope: "device",
@@ -41614,6 +41776,11 @@ Object.freeze({
41614
41776
  form: "single",
41615
41777
  optional: false
41616
41778
  }],
41779
+ "webrtcSession.getSessionDebug": [{
41780
+ name: "deviceId",
41781
+ form: "single",
41782
+ optional: false
41783
+ }],
41617
41784
  "webrtcSession.getSessionState": [{
41618
41785
  name: "deviceId",
41619
41786
  form: "single",
@@ -42477,6 +42644,106 @@ function balanceAudio(input) {
42477
42644
  };
42478
42645
  }
42479
42646
  //#endregion
42647
+ //#region src/audio-stream-selection.ts
42648
+ /**
42649
+ * Rung order for an audio consumer: cheapest first. The inverse of
42650
+ * `CAM_PROFILE_ORDER` (`high → mid → low`), derived from it rather than
42651
+ * re-spelled so the two can never disagree about which rungs exist.
42652
+ */
42653
+ var AUDIO_RUNG_PREFERENCE = [...CAM_PROFILE_ORDER].reverse();
42654
+ /**
42655
+ * True when a cam-stream id names a broker-spawned transcode plane.
42656
+ *
42657
+ * The `derived:` prefix is the repo-wide convention (`stream-broker-manager`'s
42658
+ * `derivedDefinition`, `snapshot-grab-pick`, `prefer-live-adaptive-candidates`,
42659
+ * the device-scoped stream pickers). Judged from the ID because that is what
42660
+ * the caller holds: the profile-slot table carries `sourceCamStreamId`, not the
42661
+ * published `CameraStream.kind`, and asking the broker for the whole stream
42662
+ * catalogue to re-derive a fact the id already states would add an RPC to every
42663
+ * audio subscribe.
42664
+ */
42665
+ function isDerivedCamStream(camStreamId) {
42666
+ return camStreamId.startsWith("derived:");
42667
+ }
42668
+ /** Every value the per-camera setting may hold, in the order the UI offers them. */
42669
+ var AUDIO_STREAM_PICKS = ["auto", ...CAM_PROFILE_ORDER];
42670
+ /**
42671
+ * Narrow a stored settings value to an {@link AudioStreamPick}.
42672
+ *
42673
+ * Anything that is not one of the four known values — absent, empty, a rung
42674
+ * from a schema this build does not have — reads as `auto`. That is the
42675
+ * behaviour every camera already has, so an unreadable value degrades to
42676
+ * today's rather than throwing the camera's whole detection-settings read.
42677
+ */
42678
+ function toAudioStreamPick(value) {
42679
+ return AUDIO_STREAM_PICKS.find((pick) => pick === value) ?? "auto";
42680
+ }
42681
+ /**
42682
+ * Choose the cam-stream this camera's audio analysis attaches to.
42683
+ *
42684
+ * Pure. Every branch is either a selection carrying the reason, or a refusal
42685
+ * carrying the cause — there is no third answer and no silent default.
42686
+ */
42687
+ function selectAudioCamStream(input) {
42688
+ const { pick, motionStreamId, assigned } = input;
42689
+ if (pick !== "auto") {
42690
+ if (assigned === null) return {
42691
+ kind: "refused",
42692
+ reason: "pick-unresolvable",
42693
+ pick,
42694
+ rejectedCamStreamId: null
42695
+ };
42696
+ const picked = assigned.get(pick);
42697
+ if (picked === void 0) return {
42698
+ kind: "refused",
42699
+ reason: "pick-unassigned",
42700
+ pick,
42701
+ rejectedCamStreamId: null
42702
+ };
42703
+ return {
42704
+ kind: "selected",
42705
+ camStreamId: picked,
42706
+ profile: pick,
42707
+ selectedBy: "operator-pick",
42708
+ derived: isDerivedCamStream(picked)
42709
+ };
42710
+ }
42711
+ if (assigned === null) {
42712
+ if (isDerivedCamStream(motionStreamId)) return {
42713
+ kind: "refused",
42714
+ reason: "slots-unknown-and-fallback-derived",
42715
+ pick,
42716
+ rejectedCamStreamId: motionStreamId
42717
+ };
42718
+ return {
42719
+ kind: "selected",
42720
+ camStreamId: motionStreamId,
42721
+ profile: null,
42722
+ selectedBy: "motion-stream-fallback",
42723
+ derived: false
42724
+ };
42725
+ }
42726
+ for (const profile of AUDIO_RUNG_PREFERENCE) {
42727
+ const camStreamId = assigned.get(profile);
42728
+ if (camStreamId === void 0) continue;
42729
+ if (isDerivedCamStream(camStreamId)) continue;
42730
+ return {
42731
+ kind: "selected",
42732
+ camStreamId,
42733
+ profile,
42734
+ selectedBy: "lowest-native-rung",
42735
+ derived: false
42736
+ };
42737
+ }
42738
+ const anyAssigned = AUDIO_RUNG_PREFERENCE.some((profile) => assigned.has(profile));
42739
+ return {
42740
+ kind: "refused",
42741
+ reason: anyAssigned ? "all-rungs-derived" : "no-assigned-rung",
42742
+ pick,
42743
+ rejectedCamStreamId: anyAssigned ? motionStreamId : null
42744
+ };
42745
+ }
42746
+ //#endregion
42480
42747
  //#region src/keyed-async-lock.ts
42481
42748
  /**
42482
42749
  * Serializes critical sections per key. Different keys run fully
@@ -42976,15 +43243,46 @@ var AudioSubscriptionController = class {
42976
43243
  this.deps.logger.info("audio subscribe deferred: audioMode=on-motion, no window open", { tags: { deviceId } });
42977
43244
  return null;
42978
43245
  }
42979
- const audioStream = config.audioStreamId ?? config.motionStreamId;
43246
+ const selection = selectAudioCamStream({
43247
+ pick: config.audioStreamPick ?? "auto",
43248
+ motionStreamId: config.motionStreamId,
43249
+ assigned: await this.deps.assignedProfiles(deviceId)
43250
+ });
43251
+ if (selection.kind === "refused") {
43252
+ this.deps.logger.warn("audio subscription REFUSED — no native rung to listen on (an audio-only consumer never causes a video transcode)", {
43253
+ tags: { deviceId },
43254
+ meta: {
43255
+ reason: selection.reason,
43256
+ pick: selection.pick,
43257
+ rejectedCamStreamId: selection.rejectedCamStreamId,
43258
+ motionStreamId: config.motionStreamId,
43259
+ hint: selection.reason === "pick-unassigned" ? "this camera no longer publishes the rung its audio is pinned to — repoint the Audio stream setting, or set it back to Auto" : "assign a native (non-derived) stream to one of this camera’s profile slots, or pick a rung explicitly"
43260
+ }
43261
+ });
43262
+ return null;
43263
+ }
43264
+ const audioStream = selection.camStreamId;
42980
43265
  const audioBrokerId = makeSourceBrokerId(deviceId, audioStream);
43266
+ this.deps.logger.info("audio stream selected", {
43267
+ tags: { deviceId },
43268
+ meta: {
43269
+ camStreamId: audioStream,
43270
+ profile: selection.profile,
43271
+ selectedBy: selection.selectedBy,
43272
+ derived: selection.derived,
43273
+ pick: config.audioStreamPick ?? "auto",
43274
+ motionStreamId: config.motionStreamId,
43275
+ ...selection.derived ? { note: "this pick is a derived stream — the audio subscription keeps its transcode feeder alive" } : {}
43276
+ }
43277
+ });
42981
43278
  if ((await this.deps.probeAudioTrack(deviceId, audioStream)).kind === "absent") {
42982
43279
  this.deps.logger.warn("audio subscription REFUSED — this stream carries no audio track", {
42983
43280
  tags: { deviceId },
42984
43281
  meta: {
42985
43282
  camStreamId: audioStream,
42986
43283
  brokerId: audioBrokerId,
42987
- selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
43284
+ selectedBy: selection.selectedBy,
43285
+ profile: selection.profile,
42988
43286
  hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
42989
43287
  }
42990
43288
  });
@@ -45920,6 +46218,39 @@ var pipelineOrchestratorActions = defineCustomActions({
45920
46218
  var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
45921
46219
  //#endregion
45922
46220
  //#region src/device-detection-settings.ts
46221
+ /**
46222
+ * Pure device-detection-settings derivation for the pipeline orchestrator.
46223
+ *
46224
+ * Extracted from `index.ts`'s `resolveDeviceDetectionSettings` (S3 refactor
46225
+ * step — zero behavior change). That ~185-line method interleaved I/O
46226
+ * (device-store read, binding/feature lookups) with a 5-branch
46227
+ * `motionSources` default ladder and several other operator-wins→profile→
46228
+ * fallback resolutions. The class-side method now does ONLY the I/O
46229
+ * gathering (`readDeviceStore`, `hydrateSchema`, `lookupDeviceFeatures`,
46230
+ * `deviceHasOnboardMotionCap`, `isDetectionPipelineActive`,
46231
+ * `isMotionDetectionActive`) and hands the raw materials to
46232
+ * `resolveDetectionSettings`, the pure decision half that used to be
46233
+ * interleaved with that I/O.
46234
+ *
46235
+ * Also carries:
46236
+ * - `resolveAssignedProfile` — the profile-slot fallback chain (was already
46237
+ * pure; moved verbatim, no `this` usage in the original).
46238
+ * - `detectionConfigEquals` — the field-by-field `CameraDetectionConfig`
46239
+ * comparator (was already a pure `static`; moved verbatim).
46240
+ * - `buildDetectionConfigFromInputs` — the pure tail of
46241
+ * `buildDetectionConfig`: given the resolved settings + the assigned
46242
+ * profile-slot map, resolves motion/detection profiles and shapes the
46243
+ * `CameraDetectionConfig` result.
46244
+ * - `buildRunnerConfig` — a single-source-of-truth `RunnerCameraConfig`
46245
+ * literal builder (smell #10: the literal was built field-by-field in 3
46246
+ * places — `startDetection`, `ensureMotionWatch`,
46247
+ * `dispatchDetectionSession` — differing only in `motionSources` /
46248
+ * `detectionMode` / `audioMode` / `steps` / `audio`). Wired ONLY into
46249
+ * `startDetection`'s always-on/config-build branch in this step; the two
46250
+ * Phase-3 session-time sites intentionally still build their own literals
46251
+ * inline — a later refactor step migrates them so this step never touches
46252
+ * the session-dispatch path.
46253
+ */
45923
46254
  /** Read a required string leaf out of the hydrated `flat` schema values. */
45924
46255
  function mustString(flat, deviceId, key) {
45925
46256
  const v = flat[key];
@@ -45996,6 +46327,7 @@ function resolveDetectionSettings(input) {
45996
46327
  }
45997
46328
  const userDetectionMode = raw["detectionMode"];
45998
46329
  const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
46330
+ const audioStreamPick = toAudioStreamPick(raw["audioStreamPick"]);
45999
46331
  const userAudioMode = raw["audioMode"];
46000
46332
  const audioMode = typeof userAudioMode === "string" && isPipelinePhaseMode(userAudioMode) ? userAudioMode : profile?.defaults.audioMode ?? "always-on";
46001
46333
  const userOnboardMotionDrivesAnalyzer = raw["onboardMotionDrivesAnalyzer"];
@@ -46021,6 +46353,7 @@ function resolveDetectionSettings(input) {
46021
46353
  motionCooldownMs: numberOrDefault(flat, "motionCooldownMs"),
46022
46354
  maxSessionHoldMs: numberOrDefault(flat, "maxSessionHoldMs"),
46023
46355
  audioMotionWindowMs: numberOrDefault(flat, "audioMotionWindowMs"),
46356
+ audioStreamPick,
46024
46357
  detectionMode,
46025
46358
  audioMode,
46026
46359
  onboardMotionDrivesAnalyzer,
@@ -46081,7 +46414,7 @@ function buildDetectionConfigFromInputs(resolved, assigned) {
46081
46414
  motionCooldownMs: resolved.motionCooldownMs,
46082
46415
  maxSessionHoldMs: resolved.maxSessionHoldMs,
46083
46416
  audioMotionWindowMs: resolved.audioMotionWindowMs,
46084
- audioStreamId: void 0,
46417
+ audioStreamPick: resolved.audioStreamPick,
46085
46418
  detectionMode: resolved.detectionMode,
46086
46419
  audioMode: resolved.audioMode,
46087
46420
  onboardMotionDrivesAnalyzer: resolved.onboardMotionDrivesAnalyzer,
@@ -46109,7 +46442,7 @@ function detectionConfigEquals(a, b) {
46109
46442
  if (a.motionCooldownMs !== b.motionCooldownMs) return false;
46110
46443
  if (a.maxSessionHoldMs !== b.maxSessionHoldMs) return false;
46111
46444
  if (a.audioMotionWindowMs !== b.audioMotionWindowMs) return false;
46112
- if (a.audioStreamId !== b.audioStreamId) return false;
46445
+ if (a.audioStreamPick !== b.audioStreamPick) return false;
46113
46446
  if (a.onboardMotionDrivesAnalyzer !== b.onboardMotionDrivesAnalyzer) return false;
46114
46447
  if (a.occupancyRecheckEnabled !== b.occupancyRecheckEnabled) return false;
46115
46448
  if (a.occupancyRecheckSec !== b.occupancyRecheckSec) return false;
@@ -46139,6 +46472,7 @@ function buildRunnerConfig(base, overrides) {
46139
46472
  detectionFps: config.detectionFps,
46140
46473
  motionStreamId: config.motionStreamId,
46141
46474
  detectionStreamId: config.detectionStreamId,
46475
+ ...config.audioStreamPick !== void 0 ? { audioStreamPick: config.audioStreamPick } : {},
46142
46476
  motionSources: overrides?.motionSources ?? config.motionSources,
46143
46477
  ...(() => {
46144
46478
  const sources = overrides?.detectionSources ?? config.detectionSources;
@@ -46641,8 +46975,15 @@ async function reconcileOnboardForwarding(input, deps) {
46641
46975
  /**
46642
46976
  * Derive the watchdog camera descriptor from a runner config. Captures which
46643
46977
  * stages are expected to flow CONTINUOUSLY (always-on audio, analyzer motion,
46644
- * always-on detection) → the camStreamId feeding each. Audio has no dedicated
46645
- * stream field on the runner config, so it shares the motion stream id.
46978
+ * always-on detection) → the camStreamId feeding each.
46979
+ *
46980
+ * Audio no longer shares the motion stream id. It rides the camera's lowest
46981
+ * NATIVE rung (`selectAudioCamStream`) because an audio-only consumer must
46982
+ * never cause a video transcode, and on this fleet the motion rung IS a
46983
+ * `derived:*` transcode. The caller therefore hands the resolved audio stream
46984
+ * in {@link WatchdogAudioSource} — reading `motionStreamId` here would label
46985
+ * and RECOVER the wrong stream, which is how camera 618's audio stage watched
46986
+ * a plane the analyser was not listening to.
46646
46987
  *
46647
46988
  * `pipelineEnabled === false` (the `object-detection` switch, D62/ADR-0109)
46648
46989
  * arms NOTHING: `startDetection` deliberately performs no attach and opens no
@@ -46651,12 +46992,14 @@ async function reconcileOnboardForwarding(input, deps) {
46651
46992
  * its watchdog row stays visible — a switched-off camera must read as off, not
46652
46993
  * as broken.
46653
46994
  *
46654
- * `hasAudioSource` is the caller's evidence that the camera's source actually
46995
+ * `audio.hasTrack` is the caller's evidence that the AUDIO stream actually
46655
46996
  * carries an audio track (`BrokerStats.audio !== null`). It fails OPEN: an
46656
46997
  * unknown or failed read passes `true`, because the audio stage is diagnostic
46657
- * and a false negative silently stops watching a working camera.
46998
+ * and a false negative silently stops watching a working camera. A `null`
46999
+ * `audio.camStreamId` means the subscription itself was refused (no native
47000
+ * rung, or `audioMode` cannot arm the stage) — there is nothing to watch.
46658
47001
  */
46659
- function buildWatchdogCamera(config, deviceName, hasAudioSource) {
47002
+ function buildWatchdogCamera(config, deviceName, audio) {
46660
47003
  const continuousStages = /* @__PURE__ */ new Map();
46661
47004
  if (config.pipelineEnabled === false) return {
46662
47005
  deviceId: config.deviceId,
@@ -46666,7 +47009,7 @@ function buildWatchdogCamera(config, deviceName, hasAudioSource) {
46666
47009
  motionSources: config.motionSources,
46667
47010
  continuousStages
46668
47011
  };
46669
- if (config.audioMode === "always-on" && hasAudioSource) continuousStages.set("audio", config.motionStreamId);
47012
+ if (config.audioMode === "always-on" && audio.camStreamId !== null && audio.hasTrack) continuousStages.set("audio", audio.camStreamId);
46670
47013
  if (config.motionSources.includes("analyzer")) continuousStages.set("motion", config.motionStreamId);
46671
47014
  if (config.detectionMode === "always-on") continuousStages.set("detection", config.detectionStreamId);
46672
47015
  return {
@@ -46851,6 +47194,11 @@ var DetectionWiringController = class {
46851
47194
  * `CamProfile → sourceCamStreamId` so callers can both check which
46852
47195
  * profiles are live AND resolve a profile to the cam-stream-keyed
46853
47196
  * brokerId (`${deviceId}/${camStreamId}`) used by the broker.
47197
+ *
47198
+ * Public because the AUDIO plane needs the same answer, from the same read:
47199
+ * `selectAudioCamStream` picks the lowest NATIVE rung, and a second reader
47200
+ * of the slot table would be a second authority on which rung is backed by
47201
+ * which cam-stream.
46854
47202
  */
46855
47203
  async fetchAssignedProfiles(deviceId) {
46856
47204
  const api = this.deps.api();
@@ -47230,10 +47578,10 @@ var DetectionWiringController = class {
47230
47578
  *
47231
47579
  * Public — `PlacementService`'s `buildWatchdogCamera` closure (wired in
47232
47580
  * `orchestrator-bootstrap.ts`) calls straight through to this method; the
47233
- * caller resolves `hasAudioSource` once per dispatch.
47581
+ * caller resolves the {@link WatchdogAudioSource} once per dispatch.
47234
47582
  */
47235
- buildWatchdogCamera(config, deviceName, hasAudioSource) {
47236
- return buildWatchdogCamera(config, deviceName, hasAudioSource);
47583
+ buildWatchdogCamera(config, deviceName, audio) {
47584
+ return buildWatchdogCamera(config, deviceName, audio);
47237
47585
  }
47238
47586
  /**
47239
47587
  * Watchdog recovery callback. Invoked when a camera's continuous stage
@@ -48168,8 +48516,8 @@ var PlacementService = class {
48168
48516
  await this.attachOn(targetNodeId, attachConfig);
48169
48517
  this.deps.ledger.setConfig(runnerConfig.deviceId, attachConfig);
48170
48518
  if (targetNodeId === this.deps.localNodeId()) {
48171
- const hasAudioSource = await this.resolveHasAudioSource(runnerConfig);
48172
- this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId), hasAudioSource));
48519
+ const audioSource = await this.resolveAudioSource(runnerConfig);
48520
+ this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId), audioSource));
48173
48521
  }
48174
48522
  this.deps.ledger.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, reason === "manual");
48175
48523
  } finally {
@@ -48597,19 +48945,35 @@ var PlacementService = class {
48597
48945
  }
48598
48946
  await this.attachOn(nodeId, cached);
48599
48947
  if (nodeId === this.deps.localNodeId()) {
48600
- const hasAudioSource = await this.resolveHasAudioSource(cached);
48601
- this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(cached, String(cached.deviceId), hasAudioSource));
48948
+ const audioSource = await this.resolveAudioSource(cached);
48949
+ this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(cached, String(cached.deviceId), audioSource));
48602
48950
  }
48603
48951
  }
48604
48952
  /**
48605
- * Resolve the watchdog's audio-track evidence for ONE dispatch. Skipped
48606
- * entirely (and answered `false`) when `audioMode` could not arm the stage
48607
- * anyway — `buildWatchdogCamera` needs both — so a camera with on-motion
48608
- * audio costs no broker round-trip.
48953
+ * Resolve the watchdog's audio plane for ONE dispatch: which stream audio
48954
+ * rides, and whether it carries a track. Skipped entirely (and answered
48955
+ * "nothing to watch") when `audioMode` could not arm the stage anyway, so a
48956
+ * camera with on-motion audio costs no broker round-trip.
48957
+ *
48958
+ * The stream comes from the SAME selection the subscription makes — reading
48959
+ * `motionStreamId` here would arm the stage on the derived rung the audio
48960
+ * analyser deliberately does not listen to, and both the label and the
48961
+ * recovery would name a stream nobody is on.
48609
48962
  */
48610
- async resolveHasAudioSource(config) {
48611
- if (config.audioMode !== "always-on") return false;
48612
- return this.deps.hasAudioSource(config.deviceId, config.motionStreamId);
48963
+ async resolveAudioSource(config) {
48964
+ if (config.audioMode !== "always-on") return {
48965
+ camStreamId: null,
48966
+ hasTrack: false
48967
+ };
48968
+ const camStreamId = await this.deps.audioCamStreamFor(config);
48969
+ if (camStreamId === null) return {
48970
+ camStreamId: null,
48971
+ hasTrack: false
48972
+ };
48973
+ return {
48974
+ camStreamId,
48975
+ hasTrack: await this.deps.hasAudioSource(config.deviceId, camStreamId)
48976
+ };
48613
48977
  }
48614
48978
  /**
48615
48979
  * Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
@@ -54568,6 +54932,7 @@ async function buildOrchestratorControllers(deps) {
54568
54932
  logger: deps.ctx().logger,
54569
54933
  readDeviceStore: async (deviceId) => await deps.ctxIfReady()?.settings?.readDeviceStore(deviceId) ?? {},
54570
54934
  probeAudioTrack: (deviceId, camStreamId) => resolveAudioTrackProbe(deviceId, camStreamId),
54935
+ assignedProfiles: (deviceId) => detectionWiring.fetchAssignedProfiles(deviceId),
54571
54936
  ingestNode: () => topology.clusterRoles.ingestNode,
54572
54937
  localNodeId: () => localNodeId,
54573
54938
  enabledAudioNodes: () => topology.enabledAudioNodes,
@@ -54668,6 +55033,21 @@ async function buildOrchestratorControllers(deps) {
54668
55033
  * stage that never produced one signal, with one line.
54669
55034
  */
54670
55035
  const resolveHasAudioSource = async (deviceId, camStreamId) => (await resolveAudioTrackProbe(deviceId, camStreamId)).kind !== "absent";
55036
+ /**
55037
+ * The cam-stream a camera's audio subscription rides — `selectAudioCamStream`
55038
+ * over the same profile-slot read the detection plane uses. `null` when the
55039
+ * selection refuses (every rung is a `derived:*` transcode, or nothing is
55040
+ * assigned): an audio-only consumer never causes a video transcode, so there
55041
+ * is genuinely no stream and the watchdog must not arm its audio stage.
55042
+ */
55043
+ const resolveAudioCamStream = async (config) => {
55044
+ const selection = selectAudioCamStream({
55045
+ pick: config.audioStreamPick ?? "auto",
55046
+ motionStreamId: config.motionStreamId,
55047
+ assigned: await detectionWiring.fetchAssignedProfiles(config.deviceId)
55048
+ });
55049
+ return selection.kind === "selected" ? selection.camStreamId : null;
55050
+ };
54671
55051
  const placement = new PlacementService({
54672
55052
  ctx: () => deps.ctx(),
54673
55053
  ctxIfReady: () => deps.ctxIfReady(),
@@ -54684,8 +55064,9 @@ async function buildOrchestratorControllers(deps) {
54684
55064
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
54685
55065
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
54686
55066
  inferenceRotation,
54687
- buildWatchdogCamera: (config, deviceName, hasAudioSource) => detectionWiring.buildWatchdogCamera(config, deviceName, hasAudioSource),
55067
+ buildWatchdogCamera: (config, deviceName, audio) => detectionWiring.buildWatchdogCamera(config, deviceName, audio),
54688
55068
  hasAudioSource: (deviceId, camStreamId) => resolveHasAudioSource(deviceId, camStreamId),
55069
+ audioCamStreamFor: (config) => resolveAudioCamStream(config),
54689
55070
  isIdleSessionCamera: (deviceId) => deps.isIdleSessionCamera(deviceId),
54690
55071
  activeSessionNode: (deviceId) => session.activeSessionNode(deviceId),
54691
55072
  closeActiveSession: (deviceId) => session.closeSessionForReassign(deviceId),
@@ -55488,6 +55869,34 @@ function buildDeviceSettingsSections(nodeOptions) {
55488
55869
  label: "On motion (firmware/PIR/SW)"
55489
55870
  }
55490
55871
  ]
55872
+ }, {
55873
+ key: "audioStreamPick",
55874
+ type: "select",
55875
+ label: "Audio stream",
55876
+ default: "auto",
55877
+ description: "Which camera stream the audio classifier listens on. Auto picks the lowest stream the camera serves natively — a transcoded stream is never started just to hear a camera.",
55878
+ options: [
55879
+ {
55880
+ value: "auto",
55881
+ label: "Auto (lowest native)"
55882
+ },
55883
+ {
55884
+ value: "high",
55885
+ label: "High"
55886
+ },
55887
+ {
55888
+ value: "mid",
55889
+ label: "Medium"
55890
+ },
55891
+ {
55892
+ value: "low",
55893
+ label: "Low"
55894
+ }
55895
+ ],
55896
+ showWhen: {
55897
+ field: "audioMode",
55898
+ notEquals: "disabled"
55899
+ }
55491
55900
  }]
55492
55901
  },
55493
55902
  {