@camstack/addon-pipeline-orchestrator 1.2.183 → 1.2.184

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
@@ -5348,6 +5348,86 @@ var ZodIssueCode = {
5348
5348
  /** @deprecated Do not use. Stub definition, only included for zod-to-json-schema compatibility. */
5349
5349
  var ZodFirstPartyTypeKind;
5350
5350
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5351
+ //#endregion
5352
+ //#region ../types/dist/sleep-BnujYGPe.mjs
5353
+ /**
5354
+ * The audio chunk plane's byte format, and the ONE expansion from a coded
5355
+ * window to float samples (D455).
5356
+ *
5357
+ * ## Why a format at all
5358
+ *
5359
+ * D450 took the plane off its 8 → 16 kHz upsample: it carries the SOURCE
5360
+ * RATE, and the one consumer that needs 16 kHz resamples next to the model.
5361
+ * It left the FORMAT alone — the broker still turned each G.711 byte into a
5362
+ * 4-byte f32le sample before the bytes entered the transport, so every leg of
5363
+ * the plane carried four times the source. The plane crosses hub-main twice on
5364
+ * the way to the analyzer, and the fleet's G.711 cameras are ~79 % of it.
5365
+ *
5366
+ * So the plane carries the source BYTES too, and whoever needs floats expands
5367
+ * them where it needs them. That is the same argument D450 made for the rate,
5368
+ * one step further along the same wire.
5369
+ *
5370
+ * ## Why the expansion lives here
5371
+ *
5372
+ * Two packages need it and they must never disagree: `addon-pipeline`'s broker
5373
+ * (which still has to serve a subscriber that did NOT ask for coded bytes —
5374
+ * `AudioChunkPlane` expands per subscription) and
5375
+ * `addon-pipeline-orchestrator`'s `AudioWindowAccumulator` (which flushes an
5376
+ * f32le window to the analyzer cap, whose `AudioChunkInput` contract is
5377
+ * unchanged and stays f32le). Both bundle the bare `@camstack/types` entry
5378
+ * into their own dist (`self-contained` externals), so this travels with a
5379
+ * `camstack deploy` and needs no published server.
5380
+ *
5381
+ * A second μ-law table anywhere else is the defect this module exists to
5382
+ * prevent. (`stream-broker.ts`'s `mulawToPcm` / `alawToPcm` are the ENCODE
5383
+ * direction for the WebRTC egress — a different transform, not a copy.)
5384
+ *
5385
+ * ## Absent means f32le
5386
+ *
5387
+ * `format` is optional on the wire and its absence means `f32le` — today's
5388
+ * bytes, byte for byte. A peer that never heard of the field is served what it
5389
+ * has always been served, because the broker only emits a coded window to a
5390
+ * subscription that DECLARED it accepts one (`AudioSubscribeOptions.accept`).
5391
+ * That is the D448 `rawForward` negotiation, and it is what makes this
5392
+ * deployable one addon at a time across three nodes.
5393
+ */
5394
+ /** Every byte format the audio chunk plane can carry. `f32le` is the default. */
5395
+ var AUDIO_CHUNK_FORMATS = [
5396
+ "f32le",
5397
+ "pcmu",
5398
+ "pcma"
5399
+ ];
5400
+ /**
5401
+ * Build the μ-law decode table (ITU-T G.711). Each of the 256 byte values maps
5402
+ * to a 16-bit PCM sample, normalised to [-1.0, 1.0] for f32le output.
5403
+ *
5404
+ * Moved here verbatim from `audio-rtp-decoder.ts`, which no longer decodes:
5405
+ * it buffers the coded bytes and the plane's consumers expand.
5406
+ */
5407
+ function buildUlawTable() {
5408
+ const table = new Float32Array(256);
5409
+ for (let i = 0; i < 256; i++) {
5410
+ const complemented = ~i & 255;
5411
+ const sign = (complemented & 128) !== 0 ? -1 : 1;
5412
+ const exponent = complemented >> 4 & 7;
5413
+ table[i] = sign * ((8 * (complemented & 15) + 132 << exponent) - 132) / 32768;
5414
+ }
5415
+ return table;
5416
+ }
5417
+ /** Build the A-law decode table (ITU-T G.711). */
5418
+ function buildAlawTable() {
5419
+ const table = new Float32Array(256);
5420
+ for (let i = 0; i < 256; i++) {
5421
+ const xored = i ^ 85;
5422
+ const sign = (xored & 128) !== 0 ? 1 : -1;
5423
+ const exponent = xored >> 4 & 7;
5424
+ const mantissa = xored & 15;
5425
+ table[i] = sign * (exponent === 0 ? 16 * mantissa + 8 : 16 * mantissa + 264 << exponent - 1) / 32768;
5426
+ }
5427
+ return table;
5428
+ }
5429
+ buildUlawTable();
5430
+ buildAlawTable();
5351
5431
  Object.fromEntries([
5352
5432
  {
5353
5433
  id: "overview",
@@ -6667,11 +6747,20 @@ var SubscribeFramesResultSchema = object({
6667
6747
  * (the wire-serialisable supertype of `Buffer`) to match `DecodedFrameSchema`
6668
6748
  * / `EncodedPacketSchema`'s precedent; a `Buffer` is assignable to it.
6669
6749
  */
6750
+ var AudioChunkFormatSchema = _enum(AUDIO_CHUNK_FORMATS);
6670
6751
  var DecodedAudioChunkSchema = object({
6671
6752
  data: _instanceof(Uint8Array),
6672
6753
  sampleRate: number().int().positive(),
6673
6754
  channels: number().int().positive(),
6674
- timestamp: number()
6755
+ timestamp: number(),
6756
+ /**
6757
+ * Byte format of `data`. ABSENT MEANS `f32le` — today's bytes, byte for
6758
+ * byte, for any peer that never heard of this field. A coded window
6759
+ * (`pcmu` / `pcma`, one byte per sample) is only ever emitted to a
6760
+ * subscription that DECLARED it accepts one, so absence can never mean
6761
+ * "coded bytes a consumer will read as floats" (D455).
6762
+ */
6763
+ format: AudioChunkFormatSchema.optional()
6675
6764
  });
6676
6765
  /**
6677
6766
  * Input for `stream-broker.subscribeAudioChunks` (Phase 5 / D9). The
@@ -6683,7 +6772,18 @@ var DecodedAudioChunkSchema = object({
6683
6772
  var SubscribeAudioChunksInputSchema = object({
6684
6773
  brokerId: string(),
6685
6774
  /** Short caller-identity tag (`audio-analyzer`, …) for `listClients`. */
6686
- tag: string().optional()
6775
+ tag: string().optional(),
6776
+ /**
6777
+ * Byte formats this subscriber can READ, best first. The broker serves the
6778
+ * chunk's own format when it is in this list and expands to `f32le`
6779
+ * otherwise, so a subscriber is never handed bytes it cannot interpret.
6780
+ *
6781
+ * Absent (or without the source format) means `f32le` — the behaviour every
6782
+ * subscriber had before D455, unchanged. This is the negotiation half of
6783
+ * the source-bytes lever: it is what lets the broker and its consumers
6784
+ * deploy one at a time across three nodes.
6785
+ */
6786
+ accept: array(AudioChunkFormatSchema).readonly().optional()
6687
6787
  });
6688
6788
  /** Result of `stream-broker.subscribeAudioChunks`. */
6689
6789
  var SubscribeAudioChunksResultSchema = object({
@@ -7755,7 +7855,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7755
7855
  * still gives the event loop a chance to drain — useful for breaking
7756
7856
  * up tight async loops without changing call-site semantics.
7757
7857
  */
7758
- function sleep$1(ms) {
7858
+ function sleep(ms) {
7759
7859
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7760
7860
  }
7761
7861
  //#endregion
@@ -11508,6 +11608,51 @@ var AudioAnalysisSettingsSchema = object({
11508
11608
  minConfidence: number().min(0).max(1).default(.3),
11509
11609
  allowedClasses: array(string()).default([])
11510
11610
  });
11611
+ /**
11612
+ * `attachDevice` — the analyzer PULLS a camera's audio from the broker (D461).
11613
+ *
11614
+ * Until D461 the orchestrator drained the broker's chunk plane, accumulated
11615
+ * ~1 s windows and pushed them back out as `analyseChunk`. It neither produced
11616
+ * nor consumed the audio: the PCM crossed hub-main twice for a process that
11617
+ * only buffered it. `attachDevice` inverts the direction — the analyzer opens
11618
+ * its own `subscribeAudioChunks` against the broker and the subscriber IS the
11619
+ * decoder, so the coded G.711 bytes D455 put on the plane stay coded all the
11620
+ * way to the one expansion that feeds the model.
11621
+ *
11622
+ * The orchestrator still owns the POLICY (the `audioMode` gate, the on-motion
11623
+ * window, the per-device node assignment, the settings read) and therefore
11624
+ * still owns the attach/detach pair. It no longer owns the bytes.
11625
+ */
11626
+ var AudioAttachDeviceInputSchema = object({
11627
+ deviceId: number(),
11628
+ /** Broker id (`<deviceId>/<camStreamId>`) carrying this camera's audio. */
11629
+ brokerId: string(),
11630
+ /**
11631
+ * `clusterRoles.ingestNode` — the node whose broker owns the source dial.
11632
+ * Every `streamBroker` call the attachment makes is pinned to it, exactly as
11633
+ * the orchestrator's poller pinned them before the move.
11634
+ */
11635
+ ingestNodeId: string(),
11636
+ /**
11637
+ * Resolved once by the orchestrator at attach time, exactly as it was read
11638
+ * once per subscription before D461. The analyzer does NOT re-resolve per
11639
+ * window: a settings change re-attaches, which is what always happened.
11640
+ */
11641
+ settings: AudioAnalysisSettingsSchema
11642
+ });
11643
+ var AudioAttachDeviceResultSchema = object({
11644
+ /** False only when the analyzer is shutting down and refused to attach. */
11645
+ attached: boolean(),
11646
+ /**
11647
+ * True when the attachment replaced a live one for the same device. An
11648
+ * attach is idempotent by REPLACEMENT — two pollers on one camera would
11649
+ * double the broker's fanout and neither would know about the other.
11650
+ */
11651
+ replaced: boolean()
11652
+ });
11653
+ var AudioDetachDeviceResultSchema = object({
11654
+ /** False when no attachment existed — detach is idempotent. */
11655
+ detached: boolean() });
11511
11656
  var AudioClassificationResultSchema = object({
11512
11657
  labels: array(AudioClassificationLabelSchema).readonly(),
11513
11658
  rawLabels: array(AudioClassificationLabelSchema).readonly().optional(),
@@ -11516,7 +11661,7 @@ var AudioClassificationResultSchema = object({
11516
11661
  method(object({
11517
11662
  chunk: AudioChunkInputSchema,
11518
11663
  settings: AudioAnalysisSettingsSchema
11519
- }), AudioAnalysisResultSchema.nullable(), { kind: "mutation" }), method(AudioChunkInputSchema, AudioClassificationResultSchema, { timeoutMs: 3e4 }), method(_void(), boolean()), method(_void(), _void(), { kind: "mutation" }), method(_void(), object({ backend: string() }), {
11664
+ }), AudioAnalysisResultSchema.nullable(), { kind: "mutation" }), method(AudioChunkInputSchema, AudioClassificationResultSchema, { timeoutMs: 3e4 }), method(AudioAttachDeviceInputSchema, AudioAttachDeviceResultSchema, { kind: "mutation" }), method(object({ deviceId: number() }), AudioDetachDeviceResultSchema, { kind: "mutation" }), method(_void(), boolean()), method(_void(), _void(), { kind: "mutation" }), method(_void(), object({ backend: string() }), {
11520
11665
  kind: "mutation",
11521
11666
  auth: "admin"
11522
11667
  });
@@ -32327,12 +32472,24 @@ Object.freeze({
32327
32472
  addonId: null,
32328
32473
  access: "create"
32329
32474
  },
32475
+ "audioAnalyzer.attachDevice": {
32476
+ capName: "audio-analyzer",
32477
+ capScope: "system",
32478
+ addonId: null,
32479
+ access: "create"
32480
+ },
32330
32481
  "audioAnalyzer.classify": {
32331
32482
  capName: "audio-analyzer",
32332
32483
  capScope: "system",
32333
32484
  addonId: null,
32334
32485
  access: "view"
32335
32486
  },
32487
+ "audioAnalyzer.detachDevice": {
32488
+ capName: "audio-analyzer",
32489
+ capScope: "system",
32490
+ addonId: null,
32491
+ access: "create"
32492
+ },
32336
32493
  "audioAnalyzer.dispose": {
32337
32494
  capName: "audio-analyzer",
32338
32495
  capScope: "system",
@@ -38176,11 +38333,21 @@ Object.freeze({
38176
38333
  form: "single",
38177
38334
  optional: false
38178
38335
  }],
38336
+ "audioAnalyzer.attachDevice": [{
38337
+ name: "deviceId",
38338
+ form: "single",
38339
+ optional: false
38340
+ }],
38179
38341
  "audioAnalyzer.classify": [{
38180
38342
  name: "deviceId",
38181
38343
  form: "single",
38182
38344
  optional: true
38183
38345
  }],
38346
+ "audioAnalyzer.detachDevice": [{
38347
+ name: "deviceId",
38348
+ form: "single",
38349
+ optional: false
38350
+ }],
38184
38351
  "audioMetrics.getCurrentSnapshot": [{
38185
38352
  name: "deviceId",
38186
38353
  form: "single",
@@ -40032,6 +40199,52 @@ Object.freeze({
40032
40199
  "network-access": "ingress",
40033
40200
  "smtp-provider": "email"
40034
40201
  });
40202
+ var G711_SCALE_CORRECTION_DB = {
40203
+ PCMU: 20 * Math.log10(4),
40204
+ PCMA: 20 * Math.log10(8)
40205
+ };
40206
+ /**
40207
+ * Restate a dBFS number that was MEASURED through the pre-epoch decoder as the
40208
+ * same intent on the ITU-T scale (D460).
40209
+ *
40210
+ * ## When this applies, and when it is the wrong thing to reach for
40211
+ *
40212
+ * An absolute-dBFS number in this repo is one of two things, and only one of
40213
+ * them converts:
40214
+ *
40215
+ * - **A statement about the scale** — "-55 dBFS is near silence", "-25 dBFS
40216
+ * is loud". It was true on the ITU-T scale before the epoch and it is true
40217
+ * after. The defect was never in the number; it was that 19 of this hub's
40218
+ * 25 cameras did not obey it. Converting such a number takes something
40219
+ * correct and makes it wrong, in order to preserve a bug.
40220
+ * - **A measurement taken through the old decoder** — a value someone read
40221
+ * off a meter that under-reported by exactly 4× (PCMU) or 8× (PCMA). It
40222
+ * describes a sound that was really {@link G711_SCALE_CORRECTION_DB} dB
40223
+ * louder. That is what this function is for.
40224
+ *
40225
+ * Telling the two apart is a question about PROVENANCE, not about arithmetic,
40226
+ * and it cannot be answered from the number. It is answered by the comment the
40227
+ * author left — which is why `scripts/check-dbfs-era.mts` makes leaving one
40228
+ * mandatory.
40229
+ *
40230
+ * ## Why a function and not a typed-in number
40231
+ *
40232
+ * `-55 + 12.04` written into a source file is, six months later, completely
40233
+ * indistinguishable from a threshold somebody simply preferred. Calling this
40234
+ * keeps the derivation, the law, and the original measurement all visible at
40235
+ * the call site, so a future reader can disagree with the *premise* instead of
40236
+ * having to reverse-engineer the sum.
40237
+ *
40238
+ * **This is not a runtime gain.** It converts an authored CONSTANT once, where
40239
+ * it is declared. It must never be applied to a live sample or a stored
40240
+ * `AudioEvent.dbfs`: the decoder is correct now, and a second authority
40241
+ * adjusting numbers the decoder already got right is the original defect with
40242
+ * an extra place to argue with (D459).
40243
+ */
40244
+ function ituDbfsFromPreEpoch(law, authoredDbfs) {
40245
+ return authoredDbfs + G711_SCALE_CORRECTION_DB[law];
40246
+ }
40247
+ Math.round(ituDbfsFromPreEpoch("PCMU", -55));
40035
40248
  /** Schema defaults — an untouched sub-field must author exactly these. */
40036
40249
  var NC_AUDIO_DEFAULTS = {
40037
40250
  hitPercent: 60,
@@ -40743,248 +40956,6 @@ function deviceBackendToFormat(backend) {
40743
40956
  return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
40744
40957
  }
40745
40958
  //#endregion
40746
- //#region src/audio-chunk-poller.ts
40747
- /**
40748
- * `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
40749
- * plane (Phase 5 / D9).
40750
- *
40751
- * Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
40752
- * path. A live callback cannot cross a process boundary; once the `pipeline`
40753
- * group is dissolved (Task 8) the orchestrator runs in a different process
40754
- * from the broker, so audio delivery must go over tRPC.
40755
- *
40756
- * The consumer:
40757
- *
40758
- * 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC — the broker
40759
- * registers a per-subscription bounded FIFO queue and returns a
40760
- * `subscriptionId`;
40761
- * 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
40762
- * interval, draining `DecodedAudioChunk`s in FIFO arrival order;
40763
- * 3. feeds each chunk to its downstream audio logic;
40764
- * 4. on teardown, `unsubscribeAudioChunks`.
40765
- *
40766
- * Audio is not latency-critical like video, and chunks arrive only ~every
40767
- * 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
40768
- * a small per-poll burst keeps latency low without busy-spinning. The
40769
- * broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
40770
- * loses a chunk.
40771
- *
40772
- * Boot-race tolerance: the broker for a given camStream may not be registered
40773
- * yet when the orchestrator wires the subscription (provider addons publish
40774
- * their cameraStreams asynchronously after their probe completes).
40775
- * `subscribeAudioChunks` retries with exponential backoff (capped at
40776
- * `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
40777
- * teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
40778
- * shape so video and audio plumbing self-heal identically.
40779
- */
40780
- /** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
40781
- var POLL_INTERVAL_MS$1 = 200;
40782
- /** How many chunks to drain per poll — a small burst absorbs jitter. */
40783
- var PULL_MAX_COUNT = 8;
40784
- /**
40785
- * Consecutive pull failures before we attempt to re-subscribe. A single failed
40786
- * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
40787
- * sustained failure means the broker child restarted and dropped our
40788
- * subscription, so we re-establish it.
40789
- */
40790
- var RESUBSCRIBE_AFTER_FAILURES = 2;
40791
- /** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
40792
- var RESUBSCRIBE_THROTTLE_TICKS = 5;
40793
- /** First subscribe-retry delay, doubled on every subsequent failure. */
40794
- var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
40795
- /**
40796
- * Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
40797
- * enough to recover within a single reconcile of the orchestrator and slow
40798
- * enough that a misconfigured camStream costs ~12 op/min, not 5/s.
40799
- */
40800
- var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
40801
- /**
40802
- * Attempts after which a still-failing subscribe escalates from the fast 5 s
40803
- * ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
40804
- * minute of fast retries — plenty for the boot races the 5 s ceiling exists
40805
- * for. A broker that is STILL absent after that is a long-lived condition
40806
- * (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
40807
- * to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
40808
- * churn. The slow loop stays alive so audio still recovers automatically
40809
- * (≤60 s) once the camera is re-enabled.
40810
- */
40811
- var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
40812
- var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
40813
- /**
40814
- * Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
40815
- *
40816
- * Always resolves to a teardown closure — when the broker is not yet
40817
- * registered the closure cancels the ongoing retry loop; when polling is
40818
- * active it stops the loop and releases the broker subscription. Mirrors
40819
- * `startFrameHandlePoller` so video and audio recover identically.
40820
- */
40821
- function startAudioChunkPoller(options) {
40822
- const lifecycle = {
40823
- stopped: false,
40824
- retryTimer: void 0,
40825
- pollTimer: void 0,
40826
- activeSubscriptionId: null
40827
- };
40828
- const teardown = () => {
40829
- if (lifecycle.stopped) return;
40830
- lifecycle.stopped = true;
40831
- if (lifecycle.retryTimer) {
40832
- clearTimeout(lifecycle.retryTimer);
40833
- lifecycle.retryTimer = void 0;
40834
- }
40835
- if (lifecycle.pollTimer) {
40836
- clearTimeout(lifecycle.pollTimer);
40837
- lifecycle.pollTimer = void 0;
40838
- }
40839
- const subId = lifecycle.activeSubscriptionId;
40840
- if (subId) {
40841
- lifecycle.activeSubscriptionId = null;
40842
- options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
40843
- options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
40844
- brokerId: options.brokerId,
40845
- subscriptionId: subId,
40846
- error: errMsg(err)
40847
- } });
40848
- });
40849
- }
40850
- };
40851
- subscribeWithRetry(options, lifecycle);
40852
- return teardown;
40853
- }
40854
- /**
40855
- * Run the subscribe → poll handshake with exponential backoff on subscribe
40856
- * failures. Resolves once the subscription is acquired (and the poll loop has
40857
- * been started) or once `lifecycle.stopped` flips, whichever comes first.
40858
- */
40859
- async function subscribeWithRetry(options, lifecycle) {
40860
- const { api, brokerId, tag, ownerNodeId, logger } = options;
40861
- const pin = nodePin(ownerNodeId);
40862
- let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
40863
- let attempt = 0;
40864
- while (!lifecycle.stopped) {
40865
- attempt += 1;
40866
- try {
40867
- const result = await api.streamBroker.subscribeAudioChunks.mutate({
40868
- brokerId,
40869
- tag
40870
- }, pin);
40871
- if (lifecycle.stopped) {
40872
- await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }, pin).catch((err) => {
40873
- logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
40874
- brokerId,
40875
- subscriptionId: result.subscriptionId,
40876
- error: errMsg(err)
40877
- } });
40878
- });
40879
- return;
40880
- }
40881
- if (attempt > 1) logger.info("audio-chunk poller: subscribeAudioChunks recovered", { meta: {
40882
- brokerId,
40883
- tag,
40884
- attempt
40885
- } });
40886
- lifecycle.activeSubscriptionId = result.subscriptionId;
40887
- startPolling(options, lifecycle);
40888
- return;
40889
- } catch (err) {
40890
- if (lifecycle.stopped) return;
40891
- if (attempt === 1) logger.warn("audio-chunk poller: subscribeAudioChunks failed, retrying", { meta: {
40892
- brokerId,
40893
- tag,
40894
- error: errMsg(err),
40895
- nextRetryInMs: backoffMs
40896
- } });
40897
- else logger.debug("audio-chunk poller: subscribeAudioChunks still failing", { meta: {
40898
- brokerId,
40899
- tag,
40900
- attempt,
40901
- error: errMsg(err),
40902
- nextRetryInMs: backoffMs
40903
- } });
40904
- await sleep(backoffMs, lifecycle);
40905
- backoffMs = Math.min(attempt >= PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS ? LONG_SUBSCRIBE_RETRY_BACKOFF_MS : MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
40906
- }
40907
- }
40908
- }
40909
- /**
40910
- * Drive the steady-state poll loop. Mid-stream `pullAudioChunks` failures
40911
- * trigger a fresh `subscribeAudioChunks` via the recovery branch — covers
40912
- * the broker child restart case where our `subscriptionId` is silently
40913
- * disowned.
40914
- */
40915
- function startPolling(options, lifecycle) {
40916
- const { api, brokerId, tag, ownerNodeId, onChunk, logger } = options;
40917
- const pin = nodePin(ownerNodeId);
40918
- let consecutiveFailures = 0;
40919
- const resubscribe = async () => {
40920
- try {
40921
- const result = await api.streamBroker.subscribeAudioChunks.mutate({
40922
- brokerId,
40923
- tag
40924
- }, pin);
40925
- lifecycle.activeSubscriptionId = result.subscriptionId;
40926
- logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
40927
- brokerId,
40928
- tag,
40929
- subscriptionId: result.subscriptionId,
40930
- afterFailures: consecutiveFailures
40931
- } });
40932
- return true;
40933
- } catch {
40934
- return false;
40935
- }
40936
- };
40937
- const tick = async () => {
40938
- if (lifecycle.stopped) return;
40939
- const subId = lifecycle.activeSubscriptionId;
40940
- if (!subId) return;
40941
- try {
40942
- const chunks = await api.streamBroker.pullAudioChunks.query({
40943
- subscriptionId: subId,
40944
- maxCount: PULL_MAX_COUNT
40945
- }, pin);
40946
- if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
40947
- brokerId,
40948
- subscriptionId: subId
40949
- } });
40950
- consecutiveFailures = 0;
40951
- for (const chunk of chunks) {
40952
- if (lifecycle.stopped) break;
40953
- await onChunk(chunk);
40954
- }
40955
- } catch (err) {
40956
- consecutiveFailures += 1;
40957
- if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
40958
- brokerId,
40959
- subscriptionId: subId,
40960
- error: errMsg(err)
40961
- } });
40962
- if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
40963
- }
40964
- if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), POLL_INTERVAL_MS$1);
40965
- };
40966
- tick();
40967
- }
40968
- /**
40969
- * Cancellable sleep — wakes early when `lifecycle.stopped` flips. We
40970
- * keep a local wrapper around the shared {@link sleep} helper because
40971
- * the lifecycle tracks the active retry timer for `teardown()` to
40972
- * clear; pure `sleep()` would leak the timer if teardown fired while
40973
- * we were waiting.
40974
- */
40975
- function sleep(ms, lifecycle) {
40976
- return new Promise((resolve) => {
40977
- if (lifecycle.stopped) {
40978
- resolve();
40979
- return;
40980
- }
40981
- lifecycle.retryTimer = setTimeout(() => {
40982
- lifecycle.retryTimer = void 0;
40983
- resolve();
40984
- }, ms);
40985
- });
40986
- }
40987
- //#endregion
40988
40959
  //#region src/audio-load-balancer.ts
40989
40960
  function balanceAudio(input) {
40990
40961
  if (input.nodes.length === 0) return null;
@@ -41001,278 +40972,6 @@ function balanceAudio(input) {
41001
40972
  };
41002
40973
  }
41003
40974
  //#endregion
41004
- //#region src/orchestrator-types.ts
41005
- var PHASE_MODE_VALUES = new Set([
41006
- "disabled",
41007
- "always-on",
41008
- "on-motion"
41009
- ]);
41010
- function isPipelinePhaseMode(v) {
41011
- return PHASE_MODE_VALUES.has(v);
41012
- }
41013
- /**
41014
- * Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
41015
- * that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
41016
- * `reconcileDispatch` is additive-only and never revisits these, so a slow
41017
- * safety-net timer + event-driven debounce triggers recover them.
41018
- */
41019
- var PENDING_RETRY_INTERVAL_MS = 6e4;
41020
- /** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
41021
- var PENDING_RETRY_DEBOUNCE_MS = 2e3;
41022
- /**
41023
- * Periodic auto-rebalance sweep. New attaches are already load-balanced at
41024
- * dispatch time; this corrects DRIFT that accumulates over time (uneven
41025
- * detach, a node returning online, a weight change) so the steady-state
41026
- * distribution tracks the per-node weights. Only runs with ≥2 enabled detect
41027
- * nodes, and migrates under hysteresis so a balanced cluster is a no-op.
41028
- */
41029
- var AUTO_REBALANCE_INTERVAL_MS = 6e4;
41030
- /**
41031
- * Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
41032
- * migrate a camera only when its target node is at least this much less loaded
41033
- * than its current node. > 1 so equalizing a single-camera gap (which would
41034
- * only reverse the imbalance) is skipped — prevents periodic churn.
41035
- */
41036
- var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
41037
- var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
41038
- /**
41039
- * Device-details keys routed through the orchestrator's pipeline
41040
- * settings writer instead of the device orchestration store. The
41041
- * `cameraPipeline` key carries the full `CameraPipelineConfig`
41042
- * emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
41043
- */
41044
- var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
41045
- var DEFAULT_FAILOVER_POLICY = {
41046
- onDisconnect: "migrate",
41047
- pinnedOnDisconnect: "leave-pinned",
41048
- onReconnect: "restore"
41049
- };
41050
- /**
41051
- * Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
41052
- *
41053
- * The orchestrator's cap surface is the contract for all runtime traffic
41054
- * (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
41055
- * catalog is reserved for read-only diagnostics that are intentionally
41056
- * outside the cap — they expose internal state (balancer caches, enabledNodes
41057
- * set, active detection count) that is useful for admin tooling but does not
41058
- * belong on the capability contract.
41059
- */
41060
- var OrchestratorDiagnosticsSchema = object({
41061
- localNodeId: string(),
41062
- knownRunnerNodes: array(string()),
41063
- cachedAgentLoadNodeIds: array(string()),
41064
- enabledNodes: array(string()),
41065
- enabledDecoderNodes: array(string()),
41066
- enabledAudioNodes: array(string()),
41067
- enabledIngestNodes: array(string()),
41068
- clusterRoles: object({
41069
- ingestNode: string(),
41070
- audioNode: string(),
41071
- motionNode: string()
41072
- }),
41073
- assignedDeviceCount: number().int().min(0),
41074
- cameraConfigCount: number().int().min(0),
41075
- activeDetectionCount: number().int().min(0)
41076
- });
41077
- /**
41078
- * The node-stress long-term-statistics read surface.
41079
- *
41080
- * A custom action rather than a cap method, matching how the orchestrator
41081
- * already serves `dumpState`: this is a hub-local read over a table the hub
41082
- * owns, and it ships with one `camstack deploy` instead of a release train.
41083
- * The MEAN is derived here and returned alongside the addable `sum`/`samples`
41084
- * — a chart wants the first, a re-bucketing caller wants the second, and a
41085
- * stored mean is a field that can disagree with both.
41086
- */
41087
- var NodeStressStatsInputSchema = object({
41088
- /** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
41089
- series: string().optional(),
41090
- /** A node id. Omit for every node. */
41091
- subject: string().optional(),
41092
- /** Inclusive bucket-start bounds, ms. */
41093
- from: number().int().optional(),
41094
- to: number().int().optional(),
41095
- limit: number().int().positive().max(5e3).optional()
41096
- });
41097
- var NodeStressStatsRowSchema = object({
41098
- subject: string(),
41099
- series: string(),
41100
- scope: string(),
41101
- bucketStart: number(),
41102
- samples: number(),
41103
- sum: number(),
41104
- mean: number(),
41105
- min: number(),
41106
- max: number()
41107
- });
41108
- var NodeStressStatsOutputSchema = object({
41109
- rows: array(NodeStressStatsRowSchema).readonly(),
41110
- /** Buckets still accumulating — "is it running" answerable at once, rather
41111
- * than after five minutes of indistinguishable silence. */
41112
- open: array(NodeStressStatsRowSchema).readonly(),
41113
- /** The durable failover history the anti-flap guards read, newest first.
41114
- * Exposed for the same reason the heartbeat exists: "nothing moved" has to
41115
- * be distinguishable from "nothing is watching". */
41116
- moves: array(object({
41117
- deviceId: number(),
41118
- fromNodeId: string(),
41119
- at: number()
41120
- })).readonly()
41121
- });
41122
- var pipelineOrchestratorActions = defineCustomActions({
41123
- dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
41124
- nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
41125
- });
41126
- /**
41127
- * Sentinel returned by `buildDetectionConfig` when the profile-slot READ
41128
- * itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
41129
- * while the stream-broker is (re)starting) — as opposed to `null`, which
41130
- * means "genuinely no assigned slot / not configured". Callers MUST treat
41131
- * this differently from `null`: never stop active detection on a transient
41132
- * read failure (the slots almost certainly still exist), and schedule a retry.
41133
- */
41134
- var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
41135
- //#endregion
41136
- //#region src/audio-window-accumulator.ts
41137
- function sameFormat(a, b) {
41138
- return a.sampleRate === b.sampleRate && a.channels === b.channels;
41139
- }
41140
- var AudioWindowAccumulator = class {
41141
- deviceId;
41142
- onFormat;
41143
- pcmParts = [];
41144
- accumulatedBytes = 0;
41145
- accumulatedMs = 0;
41146
- windowSampleRate = 0;
41147
- windowChannels = 0;
41148
- windowTimestamp = 0;
41149
- windowOpen = false;
41150
- /** Last format seen on this subscription — survives a flush, unlike the window anchor. */
41151
- lastFormat = null;
41152
- constructor(deviceId, onFormat) {
41153
- this.deviceId = deviceId;
41154
- this.onFormat = onFormat;
41155
- }
41156
- /**
41157
- * Append one decoded PCM chunk to the open window. Returns the flushed
41158
- * `AudioChunkInput` once the accumulated duration reaches
41159
- * `AUDIO_WINDOW_TARGET_MS` (and resets for the next window), else `null`
41160
- * (accumulate-only, no flush yet).
41161
- */
41162
- push(chunk) {
41163
- const byteLength = chunk.data.byteLength;
41164
- const bytes = new Uint8Array(byteLength);
41165
- bytes.set(new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, byteLength));
41166
- this.observeFormat({
41167
- sampleRate: chunk.sampleRate,
41168
- channels: chunk.channels
41169
- });
41170
- if (!this.windowOpen) {
41171
- this.windowSampleRate = chunk.sampleRate;
41172
- this.windowChannels = chunk.channels;
41173
- this.windowTimestamp = chunk.timestamp;
41174
- this.windowOpen = true;
41175
- }
41176
- this.pcmParts.push(bytes);
41177
- this.accumulatedBytes += byteLength;
41178
- const channels = chunk.channels > 0 ? chunk.channels : 1;
41179
- const framesPerChannel = byteLength / 4 / channels;
41180
- this.accumulatedMs += framesPerChannel / chunk.sampleRate * 1e3;
41181
- if (this.accumulatedMs < 1e3) return null;
41182
- const windowData = new Uint8Array(this.accumulatedBytes);
41183
- let offset = 0;
41184
- for (const part of this.pcmParts) {
41185
- windowData.set(part, offset);
41186
- offset += part.byteLength;
41187
- }
41188
- const flushSampleRate = this.windowSampleRate;
41189
- const flushChannels = this.windowChannels;
41190
- const flushTimestamp = this.windowTimestamp;
41191
- this.reset();
41192
- return {
41193
- data: windowData,
41194
- sampleRate: flushSampleRate,
41195
- channels: flushChannels,
41196
- timestamp: flushTimestamp,
41197
- deviceId: this.deviceId
41198
- };
41199
- }
41200
- /**
41201
- * Report the first format and every change; a change under an OPEN window
41202
- * drops the partial window so the flushed bytes never mix two rates.
41203
- */
41204
- observeFormat(format) {
41205
- const previous = this.lastFormat;
41206
- if (previous !== null && sameFormat(previous, format)) return;
41207
- const droppedMs = this.windowOpen ? Math.round(this.accumulatedMs) : 0;
41208
- if (this.windowOpen) this.reset();
41209
- this.lastFormat = format;
41210
- this.onFormat?.({
41211
- from: previous,
41212
- to: format,
41213
- droppedMs
41214
- });
41215
- }
41216
- /**
41217
- * Drop any partially-accumulated window. Called on unsubscribe / device
41218
- * audio stop (no trailing flush — a sub-second partial window is not worth
41219
- * a classify during teardown) and internally after every flush.
41220
- */
41221
- reset() {
41222
- this.pcmParts = [];
41223
- this.accumulatedBytes = 0;
41224
- this.accumulatedMs = 0;
41225
- this.windowOpen = false;
41226
- }
41227
- };
41228
- /**
41229
- * Build the canonical `AudioResult` frame from one `analyseChunk` result —
41230
- * extracted verbatim from `subscribeAudioStream`'s result→event-payload
41231
- * mapping. The caller wraps this in the `pipeline.audio-inference-result`
41232
- * event; building the frame is pure and needs no controller state.
41233
- */
41234
- function buildAudioResultFrame(deviceId, result) {
41235
- const windowId = `${deviceId}-${result.timestamp}`;
41236
- const audioDetections = [];
41237
- let idCounter = 0;
41238
- if (result.classification && result.classification.labels.length > 0) for (const label of result.classification.labels) {
41239
- idCounter += 1;
41240
- audioDetections.push({
41241
- id: `a${idCounter}`,
41242
- kind: "audio",
41243
- macroClass: label.className,
41244
- score: label.score,
41245
- labels: [{
41246
- label: label.className,
41247
- score: label.score
41248
- }],
41249
- startMs: 0,
41250
- endMs: 0
41251
- });
41252
- }
41253
- return {
41254
- kind: "audio-window",
41255
- windowId,
41256
- deviceId,
41257
- timestamp: result.timestamp,
41258
- startMs: 0,
41259
- endMs: 0,
41260
- level: {
41261
- rms: result.level.rms,
41262
- dbfs: result.level.dbfs
41263
- },
41264
- detections: audioDetections,
41265
- debug: result.classification ? {
41266
- totalInferenceMs: result.classification.inferenceMs,
41267
- stepTimings: [{
41268
- source: "audio-analyzer",
41269
- ms: result.classification.inferenceMs,
41270
- detectionCount: audioDetections.length
41271
- }]
41272
- } : void 0
41273
- };
41274
- }
41275
- //#endregion
41276
40975
  //#region src/keyed-async-lock.ts
41277
40976
  /**
41278
40977
  * Serializes critical sections per key. Different keys run fully
@@ -41800,73 +41499,41 @@ var AudioSubscriptionController = class {
41800
41499
  isRemote: isRemoteAudio
41801
41500
  }
41802
41501
  });
41803
- const accumulator = new AudioWindowAccumulator(deviceId, (change) => {
41804
- const meta = {
41805
- sampleRate: change.to.sampleRate,
41806
- channels: change.to.channels,
41807
- previous: change.from,
41808
- droppedMs: change.droppedMs
41809
- };
41810
- if (change.from === null) {
41811
- this.deps.logger.info("audio stream delivering", {
41812
- tags: { deviceId },
41813
- meta
41814
- });
41815
- return;
41816
- }
41817
- this.deps.logger.warn("audio stream format changed — partial window dropped", {
41818
- tags: { deviceId },
41819
- meta
41820
- });
41821
- });
41822
- const teardown = startAudioChunkPoller({
41823
- api,
41502
+ const attach = await api.audioAnalyzer.attachDevice.mutate({
41503
+ deviceId,
41824
41504
  brokerId: audioBrokerId,
41825
- tag: "audio-analyzer",
41826
- ownerNodeId: this.deps.ingestNode(),
41827
- logger: this.deps.logger.withTags({ deviceId }),
41828
- onChunk: async (chunk) => {
41829
- this.deps.watchdogNote(deviceId, "audio");
41830
- try {
41831
- const audioChunkInput = accumulator.push(chunk);
41832
- if (!audioChunkInput) return;
41833
- const result = await api.audioAnalyzer.analyseChunk.mutate({
41834
- chunk: audioChunkInput,
41835
- settings,
41836
- ...isRemoteAudio ? { nodeId: audioNodeId } : {}
41837
- });
41838
- if (!result) return;
41839
- const frame = buildAudioResultFrame(deviceId, result);
41840
- this.deps.eventBus.emit({
41841
- id: `audio-inference-${deviceId}-${Date.now()}`,
41842
- timestamp: /* @__PURE__ */ new Date(),
41843
- source: {
41844
- type: "device",
41845
- id: deviceId,
41846
- nodeId: "hub",
41847
- addonId: "pipeline-orchestrator",
41848
- deviceId
41849
- },
41850
- category: EventCategory.PipelineAudioInferenceResult,
41851
- data: {
41852
- deviceId,
41853
- frame,
41854
- nodeId: "hub"
41855
- }
41856
- });
41857
- } catch (err) {
41858
- const msg = errMsg(err);
41859
- this.deps.logger.error("Audio analysis failed", {
41860
- tags: { deviceId },
41861
- meta: { error: msg }
41862
- });
41505
+ ingestNodeId: this.deps.ingestNode(),
41506
+ settings
41507
+ }, nodePin(audioNodeId));
41508
+ if (!attach.attached) {
41509
+ this.deps.logger.warn("audio subscription REFUSED by the analyzer", {
41510
+ tags: { deviceId },
41511
+ meta: {
41512
+ audioNodeId,
41513
+ brokerId: audioBrokerId
41863
41514
  }
41515
+ });
41516
+ return null;
41517
+ }
41518
+ this.deps.logger.info("Audio stream subscribed", {
41519
+ tags: { deviceId },
41520
+ meta: {
41521
+ audioNodeId,
41522
+ brokerId: audioBrokerId,
41523
+ replaced: attach.replaced
41864
41524
  }
41865
41525
  });
41866
- this.deps.logger.info("Audio stream subscribed", { tags: { deviceId } });
41867
41526
  return () => {
41868
- teardown();
41869
- accumulator.reset();
41527
+ api.audioAnalyzer.detachDevice.mutate({ deviceId }, nodePin(audioNodeId)).catch((err) => {
41528
+ this.deps.logger.warn("audio detachDevice failed — the broker lease will reclaim it", {
41529
+ tags: { deviceId },
41530
+ meta: {
41531
+ audioNodeId,
41532
+ brokerId: audioBrokerId,
41533
+ error: errMsg(err)
41534
+ }
41535
+ });
41536
+ });
41870
41537
  };
41871
41538
  }
41872
41539
  /**
@@ -44615,6 +44282,138 @@ var DeviceActivitySource = class {
44615
44282
  }
44616
44283
  };
44617
44284
  //#endregion
44285
+ //#region src/orchestrator-types.ts
44286
+ var PHASE_MODE_VALUES = new Set([
44287
+ "disabled",
44288
+ "always-on",
44289
+ "on-motion"
44290
+ ]);
44291
+ function isPipelinePhaseMode(v) {
44292
+ return PHASE_MODE_VALUES.has(v);
44293
+ }
44294
+ /**
44295
+ * Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
44296
+ * that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
44297
+ * `reconcileDispatch` is additive-only and never revisits these, so a slow
44298
+ * safety-net timer + event-driven debounce triggers recover them.
44299
+ */
44300
+ var PENDING_RETRY_INTERVAL_MS = 6e4;
44301
+ /** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
44302
+ var PENDING_RETRY_DEBOUNCE_MS = 2e3;
44303
+ /**
44304
+ * Periodic auto-rebalance sweep. New attaches are already load-balanced at
44305
+ * dispatch time; this corrects DRIFT that accumulates over time (uneven
44306
+ * detach, a node returning online, a weight change) so the steady-state
44307
+ * distribution tracks the per-node weights. Only runs with ≥2 enabled detect
44308
+ * nodes, and migrates under hysteresis so a balanced cluster is a no-op.
44309
+ */
44310
+ var AUTO_REBALANCE_INTERVAL_MS = 6e4;
44311
+ /**
44312
+ * Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
44313
+ * migrate a camera only when its target node is at least this much less loaded
44314
+ * than its current node. > 1 so equalizing a single-camera gap (which would
44315
+ * only reverse the imbalance) is skipped — prevents periodic churn.
44316
+ */
44317
+ var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
44318
+ var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
44319
+ /**
44320
+ * Device-details keys routed through the orchestrator's pipeline
44321
+ * settings writer instead of the device orchestration store. The
44322
+ * `cameraPipeline` key carries the full `CameraPipelineConfig`
44323
+ * emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
44324
+ */
44325
+ var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
44326
+ var DEFAULT_FAILOVER_POLICY = {
44327
+ onDisconnect: "migrate",
44328
+ pinnedOnDisconnect: "leave-pinned",
44329
+ onReconnect: "restore"
44330
+ };
44331
+ /**
44332
+ * Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
44333
+ *
44334
+ * The orchestrator's cap surface is the contract for all runtime traffic
44335
+ * (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
44336
+ * catalog is reserved for read-only diagnostics that are intentionally
44337
+ * outside the cap — they expose internal state (balancer caches, enabledNodes
44338
+ * set, active detection count) that is useful for admin tooling but does not
44339
+ * belong on the capability contract.
44340
+ */
44341
+ var OrchestratorDiagnosticsSchema = object({
44342
+ localNodeId: string(),
44343
+ knownRunnerNodes: array(string()),
44344
+ cachedAgentLoadNodeIds: array(string()),
44345
+ enabledNodes: array(string()),
44346
+ enabledDecoderNodes: array(string()),
44347
+ enabledAudioNodes: array(string()),
44348
+ enabledIngestNodes: array(string()),
44349
+ clusterRoles: object({
44350
+ ingestNode: string(),
44351
+ audioNode: string(),
44352
+ motionNode: string()
44353
+ }),
44354
+ assignedDeviceCount: number().int().min(0),
44355
+ cameraConfigCount: number().int().min(0),
44356
+ activeDetectionCount: number().int().min(0)
44357
+ });
44358
+ /**
44359
+ * The node-stress long-term-statistics read surface.
44360
+ *
44361
+ * A custom action rather than a cap method, matching how the orchestrator
44362
+ * already serves `dumpState`: this is a hub-local read over a table the hub
44363
+ * owns, and it ships with one `camstack deploy` instead of a release train.
44364
+ * The MEAN is derived here and returned alongside the addable `sum`/`samples`
44365
+ * — a chart wants the first, a re-bucketing caller wants the second, and a
44366
+ * stored mean is a field that can disagree with both.
44367
+ */
44368
+ var NodeStressStatsInputSchema = object({
44369
+ /** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
44370
+ series: string().optional(),
44371
+ /** A node id. Omit for every node. */
44372
+ subject: string().optional(),
44373
+ /** Inclusive bucket-start bounds, ms. */
44374
+ from: number().int().optional(),
44375
+ to: number().int().optional(),
44376
+ limit: number().int().positive().max(5e3).optional()
44377
+ });
44378
+ var NodeStressStatsRowSchema = object({
44379
+ subject: string(),
44380
+ series: string(),
44381
+ scope: string(),
44382
+ bucketStart: number(),
44383
+ samples: number(),
44384
+ sum: number(),
44385
+ mean: number(),
44386
+ min: number(),
44387
+ max: number()
44388
+ });
44389
+ var NodeStressStatsOutputSchema = object({
44390
+ rows: array(NodeStressStatsRowSchema).readonly(),
44391
+ /** Buckets still accumulating — "is it running" answerable at once, rather
44392
+ * than after five minutes of indistinguishable silence. */
44393
+ open: array(NodeStressStatsRowSchema).readonly(),
44394
+ /** The durable failover history the anti-flap guards read, newest first.
44395
+ * Exposed for the same reason the heartbeat exists: "nothing moved" has to
44396
+ * be distinguishable from "nothing is watching". */
44397
+ moves: array(object({
44398
+ deviceId: number(),
44399
+ fromNodeId: string(),
44400
+ at: number()
44401
+ })).readonly()
44402
+ });
44403
+ var pipelineOrchestratorActions = defineCustomActions({
44404
+ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
44405
+ nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
44406
+ });
44407
+ /**
44408
+ * Sentinel returned by `buildDetectionConfig` when the profile-slot READ
44409
+ * itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
44410
+ * while the stream-broker is (re)starting) — as opposed to `null`, which
44411
+ * means "genuinely no assigned slot / not configured". Callers MUST treat
44412
+ * this differently from `null`: never stop active detection on a transient
44413
+ * read failure (the slots almost certainly still exist), and schedule a retry.
44414
+ */
44415
+ var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
44416
+ //#endregion
44618
44417
  //#region src/device-detection-settings.ts
44619
44418
  /** Read a required string leaf out of the hydrated `flat` schema values. */
44620
44419
  function mustString(flat, deviceId, key) {
@@ -48889,6 +48688,10 @@ function wireOrchestratorSubscriptions(deps) {
48889
48688
  const deviceId = event.source.deviceId;
48890
48689
  if (typeof deviceId === "number") deps.noteWatchdogSignal(deviceId, "motion");
48891
48690
  });
48691
+ const unsubAudioWatchdog = deps.eventBus.subscribe({ category: EventCategory.PipelineAudioInferenceResult }, (event) => {
48692
+ if (!isEvent(event, EventCategory.PipelineAudioInferenceResult)) return;
48693
+ deps.noteWatchdogSignal(event.data.deviceId, "audio");
48694
+ });
48892
48695
  const activitySource = new DeviceActivitySource({
48893
48696
  logger: deps.logger,
48894
48697
  emitMotion: (deviceId, detected, timestamp) => {
@@ -48944,6 +48747,7 @@ function wireOrchestratorSubscriptions(deps) {
48944
48747
  unsubSessionMotion();
48945
48748
  unsubFrameTracked();
48946
48749
  unsubMotionAnalysis();
48750
+ unsubAudioWatchdog();
48947
48751
  unsubDeviceActivity();
48948
48752
  };
48949
48753
  }
@@ -50054,7 +49858,7 @@ var PipelineSettingsStore = class PipelineSettingsStore {
50054
49858
  nodeId
50055
49859
  }, { timeoutMs: PipelineSettingsStore.EXECUTOR_RECONCILE_ROUND_MS }).then(() => true, () => false);
50056
49860
  if (this.disposed) break;
50057
- if (readyPerRegistry) await sleep$1(PipelineSettingsStore.EXECUTOR_READY_PROBE_PACE_MS);
49861
+ if (readyPerRegistry) await sleep(PipelineSettingsStore.EXECUTOR_READY_PROBE_PACE_MS);
50058
49862
  }
50059
49863
  return null;
50060
49864
  }
@@ -50082,13 +49886,13 @@ var PipelineSettingsStore = class PipelineSettingsStore {
50082
49886
  let agent = (await this.readAgentSettingsMap())[nodeId];
50083
49887
  if (!agent) {
50084
49888
  if (!await this.seedAgentSettingsFromCatalog(nodeId)) {
50085
- await sleep$1(2e3);
49889
+ await sleep(2e3);
50086
49890
  continue;
50087
49891
  }
50088
49892
  agent = (await this.readAgentSettingsMap())[nodeId];
50089
49893
  }
50090
49894
  if (!agent) {
50091
- await sleep$1(2e3);
49895
+ await sleep(2e3);
50092
49896
  continue;
50093
49897
  }
50094
49898
  return {
@@ -53113,7 +52917,6 @@ async function buildOrchestratorControllers(deps) {
53113
52917
  eventBus: deps.ctx().eventBus,
53114
52918
  logger: deps.ctx().logger,
53115
52919
  readDeviceStore: async (deviceId) => await deps.ctxIfReady()?.settings?.readDeviceStore(deviceId) ?? {},
53116
- watchdogNote: (deviceId, stage) => pipelineWatchdog?.noteSignal(deviceId, stage),
53117
52920
  probeAudioTrack: (deviceId, camStreamId) => resolveAudioTrackProbe(deviceId, camStreamId),
53118
52921
  ingestNode: () => topology.clusterRoles.ingestNode,
53119
52922
  localNodeId: () => localNodeId,