@camstack/addon-pipeline-orchestrator 1.2.222 → 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.js CHANGED
@@ -5391,7 +5391,7 @@ var ZodIssueCode = {
5391
5391
  var ZodFirstPartyTypeKind;
5392
5392
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5393
5393
  //#endregion
5394
- //#region ../types/dist/sleep-GU_us3DG.mjs
5394
+ //#region ../types/dist/sleep-CiR4z7r9.mjs
5395
5395
  /**
5396
5396
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5397
5397
  * window to float samples (D455).
@@ -6549,6 +6549,23 @@ var CamProfileSchema = _enum([
6549
6549
  "mid",
6550
6550
  "low"
6551
6551
  ]);
6552
+ /** Canonical ordering. Hard-coded; never sorted. */
6553
+ var CAM_PROFILE_ORDER = [
6554
+ "high",
6555
+ "mid",
6556
+ "low"
6557
+ ];
6558
+ /**
6559
+ * Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
6560
+ *
6561
+ * `auto` is a REAL value, not an absent one — it means "resolve it by the rule
6562
+ * that consumer owns". It lives beside `CamProfile` because a rung is the only
6563
+ * operator-facing way to name a stream: a cam-stream id is broker-internal and
6564
+ * moves when a slot is reassigned, so a stored id silently stops meaning what
6565
+ * the operator picked. Today's one user is the audio analyser, whose rule is
6566
+ * "the lowest AVAILABLE native rung" (D537).
6567
+ */
6568
+ var AudioStreamPickSchema = union([CamProfileSchema, literal("auto")]);
6552
6569
  var CamStreamKindSchema = _enum([
6553
6570
  "pull-rtsp",
6554
6571
  "pull-rtmp",
@@ -21032,6 +21049,7 @@ var MediaFileKindEnum = _enum([
21032
21049
  "keyFrameSmall",
21033
21050
  "thumbnailSmall",
21034
21051
  "confirmationCrop",
21052
+ "debugCrop",
21035
21053
  "sceneFrame"
21036
21054
  ]);
21037
21055
  /**
@@ -22696,6 +22714,18 @@ var RunnerCameraConfigSchema = object({
22696
22714
  motionStreamId: string(),
22697
22715
  detectionStreamId: string(),
22698
22716
  /**
22717
+ * Which rung this camera's audio analysis listens on — `auto` (the lowest
22718
+ * AVAILABLE native rung) or an explicit one.
22719
+ *
22720
+ * Not consumed by the runner: carried here so it travels with the camera's
22721
+ * config the way `audioMotionWindowMs` does, and so the orchestrator's
22722
+ * watchdog resolves the same stream the audio subscription did. OPTIONAL
22723
+ * with no default — absent means `auto`, which is what every camera written
22724
+ * before this field had, and an absent optional adds nothing to the
22725
+ * generated cap-input defaults (D537).
22726
+ */
22727
+ audioStreamPick: AudioStreamPickSchema.optional(),
22728
+ /**
22699
22729
  * Motion sources that can activate the pipeline. At least one must
22700
22730
  * be present. Multiple sources OR-ed: any one firing arms the
22701
22731
  * cooldown timer and transitions watching → active. The same
@@ -25718,6 +25748,104 @@ var WebrtcStreamChoiceSchema = object({
25718
25748
  /** Bitrate in kbps (null for adaptive). */
25719
25749
  bitrateKbps: number().nullable()
25720
25750
  });
25751
+ /**
25752
+ * The last move the adaptive ladder made on this session, and the numbers it
25753
+ * rested on. Mirrors `LadderDecision` in the stream-broker's
25754
+ * `adaptive-controller.ts` — the log line's evidence, made readable.
25755
+ *
25756
+ * Nullability is load-bearing: `lossRatio: null` means the loss tracker had
25757
+ * no window to report, which is NOT a link with zero loss.
25758
+ */
25759
+ var WebrtcLadderDecisionSchema = object({
25760
+ /** `loss` — RTCP / client-reported packet loss; `stall` — the picture
25761
+ * stopped moving; `healthy` — a promotion. */
25762
+ cause: _enum([
25763
+ "loss",
25764
+ "stall",
25765
+ "healthy"
25766
+ ]),
25767
+ /** The rung the ladder moved TO. Null for a move that changed no rung
25768
+ * (an MTU reduction, a forced transcode-down at the bottom). */
25769
+ tier: CamProfileSchema.nullable(),
25770
+ /** Whether that move forced the egress transcode-down rung. */
25771
+ forcedTranscodeDown: boolean(),
25772
+ /** Loss ratio over the tracker's window at the moment of the decision. */
25773
+ lossRatio: number().nullable(),
25774
+ /** The client's reported downlink at that moment, in kbps. */
25775
+ clientKbps: number().nullable(),
25776
+ /** How long the stall detector had been seeing nothing arrive. */
25777
+ stalledForMs: number().nullable(),
25778
+ /** Whether the broker WAS sending while that was measured — the half that
25779
+ * separates "the camera stopped" from "the link stopped". */
25780
+ serverSending: boolean().nullable(),
25781
+ /** Epoch ms. The overlay prints its age; a decision from 40 minutes ago
25782
+ * must not read like one from this second. */
25783
+ at: number()
25784
+ });
25785
+ /** Per-tier count of MEASURED downgrades this viewer session has suffered. */
25786
+ var WebrtcTierFailureSchema = object({
25787
+ tier: CamProfileSchema,
25788
+ failures: number().int().nonnegative()
25789
+ });
25790
+ /**
25791
+ * What the SERVER decided for one WebRTC session — the half the client cannot
25792
+ * measure. See `getSessionDebug`.
25793
+ */
25794
+ var WebrtcSessionDebugSchema = object({
25795
+ sessionId: string(),
25796
+ /** Whether the session still exists here. Everything below is null when
25797
+ * it does not — a dead session has no measurements, not zero ones. */
25798
+ alive: boolean(),
25799
+ /** Epoch ms this snapshot was taken, so the overlay can show its age. */
25800
+ observedAt: number(),
25801
+ /** The broker actually feeding this session (`<deviceId>/<camStreamId>`). */
25802
+ brokerId: string().nullable(),
25803
+ camStreamId: string().nullable(),
25804
+ /** `derived` = this hub BUILT the rung being served (a transcode/scale of
25805
+ * another stream); `native` = the camera's own encode. */
25806
+ sourceKind: _enum([
25807
+ "native",
25808
+ "derived",
25809
+ "unknown"
25810
+ ]),
25811
+ /** Whether the ladder is running at all (an `adaptive` target). */
25812
+ adaptive: boolean(),
25813
+ /** The rung `auto` ACTUALLY resolved to right now. Null when the broker
25814
+ * is bound to no profile slot (a manually-activated stream). */
25815
+ servedTier: CamProfileSchema.nullable(),
25816
+ /** Which node ingests the source. Null when unresolved. */
25817
+ ingestNodeId: string().nullable(),
25818
+ /** Whether that node is a remote one (the media crosses a node boundary
25819
+ * before it reaches this session). Null when unresolved. */
25820
+ ingestIsRemote: boolean().nullable(),
25821
+ /** Codec off the source, and the one negotiated with this client. */
25822
+ sourceCodec: string().nullable(),
25823
+ negotiatedCodec: string().nullable(),
25824
+ /** Whether the EGRESS leg re-encodes for this client or forwards the
25825
+ * source bytes untouched. */
25826
+ egressLeg: _enum([
25827
+ "copy",
25828
+ "transcode",
25829
+ "unknown"
25830
+ ]),
25831
+ /** What the broker itself reports about the rung it is serving. */
25832
+ brokerStatus: string().nullable(),
25833
+ brokerCodec: string().nullable(),
25834
+ brokerBitrateKbps: number().nullable(),
25835
+ brokerInputFps: number().nullable(),
25836
+ brokerUptimeMs: number().nullable(),
25837
+ /** RTP packets this session has sent — the server's own "am I sending?". */
25838
+ rtpPacketsSent: number().nullable(),
25839
+ /** D42: the track may be carrying the recorded feeder, not live. The ladder
25840
+ * is suspended there, so a frozen picture in `recorded` mode means
25841
+ * something entirely different. */
25842
+ trackMode: _enum(["live", "recorded"]).nullable(),
25843
+ lastDecision: WebrtcLadderDecisionSchema.nullable(),
25844
+ tierFailures: array(WebrtcTierFailureSchema),
25845
+ /** Successful promotions in this viewer session (drives the progressive
25846
+ * healthy timer). Null when no ladder is running. */
25847
+ upgradeCount: number().nullable()
25848
+ });
25721
25849
  DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(WebrtcStreamChoiceSchema)), method(object({
25722
25850
  deviceId: number().int().nonnegative(),
25723
25851
  target: WebrtcStreamTargetSchema,
@@ -25882,7 +26010,10 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
25882
26010
  * the same answer as "it ended": either way nothing is holding it up.
25883
26011
  */
25884
26012
  alive: boolean()
25885
- }));
26013
+ })), method(object({
26014
+ deviceId: number().int().nonnegative(),
26015
+ sessionId: string()
26016
+ }), WebrtcSessionDebugSchema, { kind: "query" });
25886
26017
  object({
25887
26018
  /** All accessory children of the parent. */
25888
26019
  childDeviceIds: array(number()).readonly(),
@@ -28970,16 +29101,37 @@ var GridProfileOfferSchema = object({
28970
29101
  ]),
28971
29102
  offered: boolean(),
28972
29103
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
28973
- missingSources: array(number().int().positive())
29104
+ missingSources: array(number().int().positive()),
29105
+ /**
29106
+ * The canvas this profile composes onto, `WxH`, or empty when it is not
29107
+ * offered. DERIVED from the cells and the sources' own size at this profile —
29108
+ * it is reported because nothing else in the system would ever say what the
29109
+ * grid came out as, and because it is the number an operator would otherwise
29110
+ * expect to type.
29111
+ */
29112
+ canvas: string(),
29113
+ /**
29114
+ * Whether this profile is PUBLISHED, of the ones the grid could serve.
29115
+ *
29116
+ * A grid's `high` is composed of its sources' `high`, so on a 4K fleet it is
29117
+ * a 4K canvas built from 4K decodes — something to opt into, not something a
29118
+ * viewer's adaptive should be handed by climbing to the top rung it can see.
29119
+ * Default is `mid` + `low`.
29120
+ */
29121
+ published: boolean()
28974
29122
  });
28975
29123
  var GridLayoutViewSchema = object({
28976
29124
  /** The persisted grid row this camera was declared from. */
28977
29125
  instanceId: string(),
28978
29126
  deviceId: number().int().nonnegative(),
28979
29127
  name: string(),
28980
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
28981
- width: number().int(),
28982
- height: number().int(),
29128
+ /**
29129
+ * NO canvas size. A grid's resolution is not authored: each profile derives
29130
+ * its own from the cells and its sources' dimensions. The two numbers that
29131
+ * used to be here were a text field that silently decided both how much the
29132
+ * composite cost and how sharp it was — see `profiles[].canvas` for what it
29133
+ * came out as.
29134
+ */
28983
29135
  fps: number().int(),
28984
29136
  cells: array(GridLayoutCellSchema),
28985
29137
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -28988,9 +29140,13 @@ var GridLayoutViewSchema = object({
28988
29140
  var GridLayoutPatchSchema = object({
28989
29141
  deviceId: number().int().nonnegative(),
28990
29142
  name: string().min(1).max(160).optional(),
28991
- width: number().int().min(160).max(7680).optional(),
28992
- height: number().int().min(120).max(4320).optional(),
28993
29143
  fps: number().int().min(1).max(60).optional(),
29144
+ /** Which profiles to publish. See `GridProfileOffer.published`. */
29145
+ publishedProfiles: array(_enum([
29146
+ "high",
29147
+ "mid",
29148
+ "low"
29149
+ ])).max(3).optional(),
28994
29150
  /**
28995
29151
  * The whole cell list at once. A per-cell patch would need an ordering the
28996
29152
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -39659,6 +39815,12 @@ Object.freeze({
39659
39815
  addonId: null,
39660
39816
  access: "view"
39661
39817
  },
39818
+ "webrtcSession.getSessionDebug": {
39819
+ capName: "webrtc-session",
39820
+ capScope: "device",
39821
+ addonId: null,
39822
+ access: "view"
39823
+ },
39662
39824
  "webrtcSession.getSessionState": {
39663
39825
  capName: "webrtc-session",
39664
39826
  capScope: "device",
@@ -41642,6 +41804,11 @@ Object.freeze({
41642
41804
  form: "single",
41643
41805
  optional: false
41644
41806
  }],
41807
+ "webrtcSession.getSessionDebug": [{
41808
+ name: "deviceId",
41809
+ form: "single",
41810
+ optional: false
41811
+ }],
41645
41812
  "webrtcSession.getSessionState": [{
41646
41813
  name: "deviceId",
41647
41814
  form: "single",
@@ -42505,6 +42672,106 @@ function balanceAudio(input) {
42505
42672
  };
42506
42673
  }
42507
42674
  //#endregion
42675
+ //#region src/audio-stream-selection.ts
42676
+ /**
42677
+ * Rung order for an audio consumer: cheapest first. The inverse of
42678
+ * `CAM_PROFILE_ORDER` (`high → mid → low`), derived from it rather than
42679
+ * re-spelled so the two can never disagree about which rungs exist.
42680
+ */
42681
+ var AUDIO_RUNG_PREFERENCE = [...CAM_PROFILE_ORDER].reverse();
42682
+ /**
42683
+ * True when a cam-stream id names a broker-spawned transcode plane.
42684
+ *
42685
+ * The `derived:` prefix is the repo-wide convention (`stream-broker-manager`'s
42686
+ * `derivedDefinition`, `snapshot-grab-pick`, `prefer-live-adaptive-candidates`,
42687
+ * the device-scoped stream pickers). Judged from the ID because that is what
42688
+ * the caller holds: the profile-slot table carries `sourceCamStreamId`, not the
42689
+ * published `CameraStream.kind`, and asking the broker for the whole stream
42690
+ * catalogue to re-derive a fact the id already states would add an RPC to every
42691
+ * audio subscribe.
42692
+ */
42693
+ function isDerivedCamStream(camStreamId) {
42694
+ return camStreamId.startsWith("derived:");
42695
+ }
42696
+ /** Every value the per-camera setting may hold, in the order the UI offers them. */
42697
+ var AUDIO_STREAM_PICKS = ["auto", ...CAM_PROFILE_ORDER];
42698
+ /**
42699
+ * Narrow a stored settings value to an {@link AudioStreamPick}.
42700
+ *
42701
+ * Anything that is not one of the four known values — absent, empty, a rung
42702
+ * from a schema this build does not have — reads as `auto`. That is the
42703
+ * behaviour every camera already has, so an unreadable value degrades to
42704
+ * today's rather than throwing the camera's whole detection-settings read.
42705
+ */
42706
+ function toAudioStreamPick(value) {
42707
+ return AUDIO_STREAM_PICKS.find((pick) => pick === value) ?? "auto";
42708
+ }
42709
+ /**
42710
+ * Choose the cam-stream this camera's audio analysis attaches to.
42711
+ *
42712
+ * Pure. Every branch is either a selection carrying the reason, or a refusal
42713
+ * carrying the cause — there is no third answer and no silent default.
42714
+ */
42715
+ function selectAudioCamStream(input) {
42716
+ const { pick, motionStreamId, assigned } = input;
42717
+ if (pick !== "auto") {
42718
+ if (assigned === null) return {
42719
+ kind: "refused",
42720
+ reason: "pick-unresolvable",
42721
+ pick,
42722
+ rejectedCamStreamId: null
42723
+ };
42724
+ const picked = assigned.get(pick);
42725
+ if (picked === void 0) return {
42726
+ kind: "refused",
42727
+ reason: "pick-unassigned",
42728
+ pick,
42729
+ rejectedCamStreamId: null
42730
+ };
42731
+ return {
42732
+ kind: "selected",
42733
+ camStreamId: picked,
42734
+ profile: pick,
42735
+ selectedBy: "operator-pick",
42736
+ derived: isDerivedCamStream(picked)
42737
+ };
42738
+ }
42739
+ if (assigned === null) {
42740
+ if (isDerivedCamStream(motionStreamId)) return {
42741
+ kind: "refused",
42742
+ reason: "slots-unknown-and-fallback-derived",
42743
+ pick,
42744
+ rejectedCamStreamId: motionStreamId
42745
+ };
42746
+ return {
42747
+ kind: "selected",
42748
+ camStreamId: motionStreamId,
42749
+ profile: null,
42750
+ selectedBy: "motion-stream-fallback",
42751
+ derived: false
42752
+ };
42753
+ }
42754
+ for (const profile of AUDIO_RUNG_PREFERENCE) {
42755
+ const camStreamId = assigned.get(profile);
42756
+ if (camStreamId === void 0) continue;
42757
+ if (isDerivedCamStream(camStreamId)) continue;
42758
+ return {
42759
+ kind: "selected",
42760
+ camStreamId,
42761
+ profile,
42762
+ selectedBy: "lowest-native-rung",
42763
+ derived: false
42764
+ };
42765
+ }
42766
+ const anyAssigned = AUDIO_RUNG_PREFERENCE.some((profile) => assigned.has(profile));
42767
+ return {
42768
+ kind: "refused",
42769
+ reason: anyAssigned ? "all-rungs-derived" : "no-assigned-rung",
42770
+ pick,
42771
+ rejectedCamStreamId: anyAssigned ? motionStreamId : null
42772
+ };
42773
+ }
42774
+ //#endregion
42508
42775
  //#region src/keyed-async-lock.ts
42509
42776
  /**
42510
42777
  * Serializes critical sections per key. Different keys run fully
@@ -43004,15 +43271,46 @@ var AudioSubscriptionController = class {
43004
43271
  this.deps.logger.info("audio subscribe deferred: audioMode=on-motion, no window open", { tags: { deviceId } });
43005
43272
  return null;
43006
43273
  }
43007
- const audioStream = config.audioStreamId ?? config.motionStreamId;
43274
+ const selection = selectAudioCamStream({
43275
+ pick: config.audioStreamPick ?? "auto",
43276
+ motionStreamId: config.motionStreamId,
43277
+ assigned: await this.deps.assignedProfiles(deviceId)
43278
+ });
43279
+ if (selection.kind === "refused") {
43280
+ this.deps.logger.warn("audio subscription REFUSED — no native rung to listen on (an audio-only consumer never causes a video transcode)", {
43281
+ tags: { deviceId },
43282
+ meta: {
43283
+ reason: selection.reason,
43284
+ pick: selection.pick,
43285
+ rejectedCamStreamId: selection.rejectedCamStreamId,
43286
+ motionStreamId: config.motionStreamId,
43287
+ 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"
43288
+ }
43289
+ });
43290
+ return null;
43291
+ }
43292
+ const audioStream = selection.camStreamId;
43008
43293
  const audioBrokerId = makeSourceBrokerId(deviceId, audioStream);
43294
+ this.deps.logger.info("audio stream selected", {
43295
+ tags: { deviceId },
43296
+ meta: {
43297
+ camStreamId: audioStream,
43298
+ profile: selection.profile,
43299
+ selectedBy: selection.selectedBy,
43300
+ derived: selection.derived,
43301
+ pick: config.audioStreamPick ?? "auto",
43302
+ motionStreamId: config.motionStreamId,
43303
+ ...selection.derived ? { note: "this pick is a derived stream — the audio subscription keeps its transcode feeder alive" } : {}
43304
+ }
43305
+ });
43009
43306
  if ((await this.deps.probeAudioTrack(deviceId, audioStream)).kind === "absent") {
43010
43307
  this.deps.logger.warn("audio subscription REFUSED — this stream carries no audio track", {
43011
43308
  tags: { deviceId },
43012
43309
  meta: {
43013
43310
  camStreamId: audioStream,
43014
43311
  brokerId: audioBrokerId,
43015
- selectedBy: config.audioStreamId !== void 0 ? "audioStreamId" : "motionStreamId",
43312
+ selectedBy: selection.selectedBy,
43313
+ profile: selection.profile,
43016
43314
  hint: "point the camera’s audio at a stream that has an audio track — no stream is substituted automatically"
43017
43315
  }
43018
43316
  });
@@ -45948,6 +46246,39 @@ var pipelineOrchestratorActions = defineCustomActions({
45948
46246
  var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
45949
46247
  //#endregion
45950
46248
  //#region src/device-detection-settings.ts
46249
+ /**
46250
+ * Pure device-detection-settings derivation for the pipeline orchestrator.
46251
+ *
46252
+ * Extracted from `index.ts`'s `resolveDeviceDetectionSettings` (S3 refactor
46253
+ * step — zero behavior change). That ~185-line method interleaved I/O
46254
+ * (device-store read, binding/feature lookups) with a 5-branch
46255
+ * `motionSources` default ladder and several other operator-wins→profile→
46256
+ * fallback resolutions. The class-side method now does ONLY the I/O
46257
+ * gathering (`readDeviceStore`, `hydrateSchema`, `lookupDeviceFeatures`,
46258
+ * `deviceHasOnboardMotionCap`, `isDetectionPipelineActive`,
46259
+ * `isMotionDetectionActive`) and hands the raw materials to
46260
+ * `resolveDetectionSettings`, the pure decision half that used to be
46261
+ * interleaved with that I/O.
46262
+ *
46263
+ * Also carries:
46264
+ * - `resolveAssignedProfile` — the profile-slot fallback chain (was already
46265
+ * pure; moved verbatim, no `this` usage in the original).
46266
+ * - `detectionConfigEquals` — the field-by-field `CameraDetectionConfig`
46267
+ * comparator (was already a pure `static`; moved verbatim).
46268
+ * - `buildDetectionConfigFromInputs` — the pure tail of
46269
+ * `buildDetectionConfig`: given the resolved settings + the assigned
46270
+ * profile-slot map, resolves motion/detection profiles and shapes the
46271
+ * `CameraDetectionConfig` result.
46272
+ * - `buildRunnerConfig` — a single-source-of-truth `RunnerCameraConfig`
46273
+ * literal builder (smell #10: the literal was built field-by-field in 3
46274
+ * places — `startDetection`, `ensureMotionWatch`,
46275
+ * `dispatchDetectionSession` — differing only in `motionSources` /
46276
+ * `detectionMode` / `audioMode` / `steps` / `audio`). Wired ONLY into
46277
+ * `startDetection`'s always-on/config-build branch in this step; the two
46278
+ * Phase-3 session-time sites intentionally still build their own literals
46279
+ * inline — a later refactor step migrates them so this step never touches
46280
+ * the session-dispatch path.
46281
+ */
45951
46282
  /** Read a required string leaf out of the hydrated `flat` schema values. */
45952
46283
  function mustString(flat, deviceId, key) {
45953
46284
  const v = flat[key];
@@ -46024,6 +46355,7 @@ function resolveDetectionSettings(input) {
46024
46355
  }
46025
46356
  const userDetectionMode = raw["detectionMode"];
46026
46357
  const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
46358
+ const audioStreamPick = toAudioStreamPick(raw["audioStreamPick"]);
46027
46359
  const userAudioMode = raw["audioMode"];
46028
46360
  const audioMode = typeof userAudioMode === "string" && isPipelinePhaseMode(userAudioMode) ? userAudioMode : profile?.defaults.audioMode ?? "always-on";
46029
46361
  const userOnboardMotionDrivesAnalyzer = raw["onboardMotionDrivesAnalyzer"];
@@ -46049,6 +46381,7 @@ function resolveDetectionSettings(input) {
46049
46381
  motionCooldownMs: numberOrDefault(flat, "motionCooldownMs"),
46050
46382
  maxSessionHoldMs: numberOrDefault(flat, "maxSessionHoldMs"),
46051
46383
  audioMotionWindowMs: numberOrDefault(flat, "audioMotionWindowMs"),
46384
+ audioStreamPick,
46052
46385
  detectionMode,
46053
46386
  audioMode,
46054
46387
  onboardMotionDrivesAnalyzer,
@@ -46109,7 +46442,7 @@ function buildDetectionConfigFromInputs(resolved, assigned) {
46109
46442
  motionCooldownMs: resolved.motionCooldownMs,
46110
46443
  maxSessionHoldMs: resolved.maxSessionHoldMs,
46111
46444
  audioMotionWindowMs: resolved.audioMotionWindowMs,
46112
- audioStreamId: void 0,
46445
+ audioStreamPick: resolved.audioStreamPick,
46113
46446
  detectionMode: resolved.detectionMode,
46114
46447
  audioMode: resolved.audioMode,
46115
46448
  onboardMotionDrivesAnalyzer: resolved.onboardMotionDrivesAnalyzer,
@@ -46137,7 +46470,7 @@ function detectionConfigEquals(a, b) {
46137
46470
  if (a.motionCooldownMs !== b.motionCooldownMs) return false;
46138
46471
  if (a.maxSessionHoldMs !== b.maxSessionHoldMs) return false;
46139
46472
  if (a.audioMotionWindowMs !== b.audioMotionWindowMs) return false;
46140
- if (a.audioStreamId !== b.audioStreamId) return false;
46473
+ if (a.audioStreamPick !== b.audioStreamPick) return false;
46141
46474
  if (a.onboardMotionDrivesAnalyzer !== b.onboardMotionDrivesAnalyzer) return false;
46142
46475
  if (a.occupancyRecheckEnabled !== b.occupancyRecheckEnabled) return false;
46143
46476
  if (a.occupancyRecheckSec !== b.occupancyRecheckSec) return false;
@@ -46167,6 +46500,7 @@ function buildRunnerConfig(base, overrides) {
46167
46500
  detectionFps: config.detectionFps,
46168
46501
  motionStreamId: config.motionStreamId,
46169
46502
  detectionStreamId: config.detectionStreamId,
46503
+ ...config.audioStreamPick !== void 0 ? { audioStreamPick: config.audioStreamPick } : {},
46170
46504
  motionSources: overrides?.motionSources ?? config.motionSources,
46171
46505
  ...(() => {
46172
46506
  const sources = overrides?.detectionSources ?? config.detectionSources;
@@ -46669,8 +47003,15 @@ async function reconcileOnboardForwarding(input, deps) {
46669
47003
  /**
46670
47004
  * Derive the watchdog camera descriptor from a runner config. Captures which
46671
47005
  * stages are expected to flow CONTINUOUSLY (always-on audio, analyzer motion,
46672
- * always-on detection) → the camStreamId feeding each. Audio has no dedicated
46673
- * stream field on the runner config, so it shares the motion stream id.
47006
+ * always-on detection) → the camStreamId feeding each.
47007
+ *
47008
+ * Audio no longer shares the motion stream id. It rides the camera's lowest
47009
+ * NATIVE rung (`selectAudioCamStream`) because an audio-only consumer must
47010
+ * never cause a video transcode, and on this fleet the motion rung IS a
47011
+ * `derived:*` transcode. The caller therefore hands the resolved audio stream
47012
+ * in {@link WatchdogAudioSource} — reading `motionStreamId` here would label
47013
+ * and RECOVER the wrong stream, which is how camera 618's audio stage watched
47014
+ * a plane the analyser was not listening to.
46674
47015
  *
46675
47016
  * `pipelineEnabled === false` (the `object-detection` switch, D62/ADR-0109)
46676
47017
  * arms NOTHING: `startDetection` deliberately performs no attach and opens no
@@ -46679,12 +47020,14 @@ async function reconcileOnboardForwarding(input, deps) {
46679
47020
  * its watchdog row stays visible — a switched-off camera must read as off, not
46680
47021
  * as broken.
46681
47022
  *
46682
- * `hasAudioSource` is the caller's evidence that the camera's source actually
47023
+ * `audio.hasTrack` is the caller's evidence that the AUDIO stream actually
46683
47024
  * carries an audio track (`BrokerStats.audio !== null`). It fails OPEN: an
46684
47025
  * unknown or failed read passes `true`, because the audio stage is diagnostic
46685
- * and a false negative silently stops watching a working camera.
47026
+ * and a false negative silently stops watching a working camera. A `null`
47027
+ * `audio.camStreamId` means the subscription itself was refused (no native
47028
+ * rung, or `audioMode` cannot arm the stage) — there is nothing to watch.
46686
47029
  */
46687
- function buildWatchdogCamera(config, deviceName, hasAudioSource) {
47030
+ function buildWatchdogCamera(config, deviceName, audio) {
46688
47031
  const continuousStages = /* @__PURE__ */ new Map();
46689
47032
  if (config.pipelineEnabled === false) return {
46690
47033
  deviceId: config.deviceId,
@@ -46694,7 +47037,7 @@ function buildWatchdogCamera(config, deviceName, hasAudioSource) {
46694
47037
  motionSources: config.motionSources,
46695
47038
  continuousStages
46696
47039
  };
46697
- if (config.audioMode === "always-on" && hasAudioSource) continuousStages.set("audio", config.motionStreamId);
47040
+ if (config.audioMode === "always-on" && audio.camStreamId !== null && audio.hasTrack) continuousStages.set("audio", audio.camStreamId);
46698
47041
  if (config.motionSources.includes("analyzer")) continuousStages.set("motion", config.motionStreamId);
46699
47042
  if (config.detectionMode === "always-on") continuousStages.set("detection", config.detectionStreamId);
46700
47043
  return {
@@ -46879,6 +47222,11 @@ var DetectionWiringController = class {
46879
47222
  * `CamProfile → sourceCamStreamId` so callers can both check which
46880
47223
  * profiles are live AND resolve a profile to the cam-stream-keyed
46881
47224
  * brokerId (`${deviceId}/${camStreamId}`) used by the broker.
47225
+ *
47226
+ * Public because the AUDIO plane needs the same answer, from the same read:
47227
+ * `selectAudioCamStream` picks the lowest NATIVE rung, and a second reader
47228
+ * of the slot table would be a second authority on which rung is backed by
47229
+ * which cam-stream.
46882
47230
  */
46883
47231
  async fetchAssignedProfiles(deviceId) {
46884
47232
  const api = this.deps.api();
@@ -47258,10 +47606,10 @@ var DetectionWiringController = class {
47258
47606
  *
47259
47607
  * Public — `PlacementService`'s `buildWatchdogCamera` closure (wired in
47260
47608
  * `orchestrator-bootstrap.ts`) calls straight through to this method; the
47261
- * caller resolves `hasAudioSource` once per dispatch.
47609
+ * caller resolves the {@link WatchdogAudioSource} once per dispatch.
47262
47610
  */
47263
- buildWatchdogCamera(config, deviceName, hasAudioSource) {
47264
- return buildWatchdogCamera(config, deviceName, hasAudioSource);
47611
+ buildWatchdogCamera(config, deviceName, audio) {
47612
+ return buildWatchdogCamera(config, deviceName, audio);
47265
47613
  }
47266
47614
  /**
47267
47615
  * Watchdog recovery callback. Invoked when a camera's continuous stage
@@ -48196,8 +48544,8 @@ var PlacementService = class {
48196
48544
  await this.attachOn(targetNodeId, attachConfig);
48197
48545
  this.deps.ledger.setConfig(runnerConfig.deviceId, attachConfig);
48198
48546
  if (targetNodeId === this.deps.localNodeId()) {
48199
- const hasAudioSource = await this.resolveHasAudioSource(runnerConfig);
48200
- this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId), hasAudioSource));
48547
+ const audioSource = await this.resolveAudioSource(runnerConfig);
48548
+ this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId), audioSource));
48201
48549
  }
48202
48550
  this.deps.ledger.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, reason === "manual");
48203
48551
  } finally {
@@ -48625,19 +48973,35 @@ var PlacementService = class {
48625
48973
  }
48626
48974
  await this.attachOn(nodeId, cached);
48627
48975
  if (nodeId === this.deps.localNodeId()) {
48628
- const hasAudioSource = await this.resolveHasAudioSource(cached);
48629
- this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(cached, String(cached.deviceId), hasAudioSource));
48976
+ const audioSource = await this.resolveAudioSource(cached);
48977
+ this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(cached, String(cached.deviceId), audioSource));
48630
48978
  }
48631
48979
  }
48632
48980
  /**
48633
- * Resolve the watchdog's audio-track evidence for ONE dispatch. Skipped
48634
- * entirely (and answered `false`) when `audioMode` could not arm the stage
48635
- * anyway — `buildWatchdogCamera` needs both — so a camera with on-motion
48636
- * audio costs no broker round-trip.
48981
+ * Resolve the watchdog's audio plane for ONE dispatch: which stream audio
48982
+ * rides, and whether it carries a track. Skipped entirely (and answered
48983
+ * "nothing to watch") when `audioMode` could not arm the stage anyway, so a
48984
+ * camera with on-motion audio costs no broker round-trip.
48985
+ *
48986
+ * The stream comes from the SAME selection the subscription makes — reading
48987
+ * `motionStreamId` here would arm the stage on the derived rung the audio
48988
+ * analyser deliberately does not listen to, and both the label and the
48989
+ * recovery would name a stream nobody is on.
48637
48990
  */
48638
- async resolveHasAudioSource(config) {
48639
- if (config.audioMode !== "always-on") return false;
48640
- return this.deps.hasAudioSource(config.deviceId, config.motionStreamId);
48991
+ async resolveAudioSource(config) {
48992
+ if (config.audioMode !== "always-on") return {
48993
+ camStreamId: null,
48994
+ hasTrack: false
48995
+ };
48996
+ const camStreamId = await this.deps.audioCamStreamFor(config);
48997
+ if (camStreamId === null) return {
48998
+ camStreamId: null,
48999
+ hasTrack: false
49000
+ };
49001
+ return {
49002
+ camStreamId,
49003
+ hasTrack: await this.deps.hasAudioSource(config.deviceId, camStreamId)
49004
+ };
48641
49005
  }
48642
49006
  /**
48643
49007
  * Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
@@ -54596,6 +54960,7 @@ async function buildOrchestratorControllers(deps) {
54596
54960
  logger: deps.ctx().logger,
54597
54961
  readDeviceStore: async (deviceId) => await deps.ctxIfReady()?.settings?.readDeviceStore(deviceId) ?? {},
54598
54962
  probeAudioTrack: (deviceId, camStreamId) => resolveAudioTrackProbe(deviceId, camStreamId),
54963
+ assignedProfiles: (deviceId) => detectionWiring.fetchAssignedProfiles(deviceId),
54599
54964
  ingestNode: () => topology.clusterRoles.ingestNode,
54600
54965
  localNodeId: () => localNodeId,
54601
54966
  enabledAudioNodes: () => topology.enabledAudioNodes,
@@ -54696,6 +55061,21 @@ async function buildOrchestratorControllers(deps) {
54696
55061
  * stage that never produced one signal, with one line.
54697
55062
  */
54698
55063
  const resolveHasAudioSource = async (deviceId, camStreamId) => (await resolveAudioTrackProbe(deviceId, camStreamId)).kind !== "absent";
55064
+ /**
55065
+ * The cam-stream a camera's audio subscription rides — `selectAudioCamStream`
55066
+ * over the same profile-slot read the detection plane uses. `null` when the
55067
+ * selection refuses (every rung is a `derived:*` transcode, or nothing is
55068
+ * assigned): an audio-only consumer never causes a video transcode, so there
55069
+ * is genuinely no stream and the watchdog must not arm its audio stage.
55070
+ */
55071
+ const resolveAudioCamStream = async (config) => {
55072
+ const selection = selectAudioCamStream({
55073
+ pick: config.audioStreamPick ?? "auto",
55074
+ motionStreamId: config.motionStreamId,
55075
+ assigned: await detectionWiring.fetchAssignedProfiles(config.deviceId)
55076
+ });
55077
+ return selection.kind === "selected" ? selection.camStreamId : null;
55078
+ };
54699
55079
  const placement = new PlacementService({
54700
55080
  ctx: () => deps.ctx(),
54701
55081
  ctxIfReady: () => deps.ctxIfReady(),
@@ -54712,8 +55092,9 @@ async function buildOrchestratorControllers(deps) {
54712
55092
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
54713
55093
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
54714
55094
  inferenceRotation,
54715
- buildWatchdogCamera: (config, deviceName, hasAudioSource) => detectionWiring.buildWatchdogCamera(config, deviceName, hasAudioSource),
55095
+ buildWatchdogCamera: (config, deviceName, audio) => detectionWiring.buildWatchdogCamera(config, deviceName, audio),
54716
55096
  hasAudioSource: (deviceId, camStreamId) => resolveHasAudioSource(deviceId, camStreamId),
55097
+ audioCamStreamFor: (config) => resolveAudioCamStream(config),
54717
55098
  isIdleSessionCamera: (deviceId) => deps.isIdleSessionCamera(deviceId),
54718
55099
  activeSessionNode: (deviceId) => session.activeSessionNode(deviceId),
54719
55100
  closeActiveSession: (deviceId) => session.closeSessionForReassign(deviceId),
@@ -55516,6 +55897,34 @@ function buildDeviceSettingsSections(nodeOptions) {
55516
55897
  label: "On motion (firmware/PIR/SW)"
55517
55898
  }
55518
55899
  ]
55900
+ }, {
55901
+ key: "audioStreamPick",
55902
+ type: "select",
55903
+ label: "Audio stream",
55904
+ default: "auto",
55905
+ 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.",
55906
+ options: [
55907
+ {
55908
+ value: "auto",
55909
+ label: "Auto (lowest native)"
55910
+ },
55911
+ {
55912
+ value: "high",
55913
+ label: "High"
55914
+ },
55915
+ {
55916
+ value: "mid",
55917
+ label: "Medium"
55918
+ },
55919
+ {
55920
+ value: "low",
55921
+ label: "Low"
55922
+ }
55923
+ ],
55924
+ showWhen: {
55925
+ field: "audioMode",
55926
+ notEquals: "disabled"
55927
+ }
55519
55928
  }]
55520
55929
  },
55521
55930
  {