@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.js CHANGED
@@ -5376,6 +5376,86 @@ var ZodIssueCode = {
5376
5376
  /** @deprecated Do not use. Stub definition, only included for zod-to-json-schema compatibility. */
5377
5377
  var ZodFirstPartyTypeKind;
5378
5378
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5379
+ //#endregion
5380
+ //#region ../types/dist/sleep-BnujYGPe.mjs
5381
+ /**
5382
+ * The audio chunk plane's byte format, and the ONE expansion from a coded
5383
+ * window to float samples (D455).
5384
+ *
5385
+ * ## Why a format at all
5386
+ *
5387
+ * D450 took the plane off its 8 → 16 kHz upsample: it carries the SOURCE
5388
+ * RATE, and the one consumer that needs 16 kHz resamples next to the model.
5389
+ * It left the FORMAT alone — the broker still turned each G.711 byte into a
5390
+ * 4-byte f32le sample before the bytes entered the transport, so every leg of
5391
+ * the plane carried four times the source. The plane crosses hub-main twice on
5392
+ * the way to the analyzer, and the fleet's G.711 cameras are ~79 % of it.
5393
+ *
5394
+ * So the plane carries the source BYTES too, and whoever needs floats expands
5395
+ * them where it needs them. That is the same argument D450 made for the rate,
5396
+ * one step further along the same wire.
5397
+ *
5398
+ * ## Why the expansion lives here
5399
+ *
5400
+ * Two packages need it and they must never disagree: `addon-pipeline`'s broker
5401
+ * (which still has to serve a subscriber that did NOT ask for coded bytes —
5402
+ * `AudioChunkPlane` expands per subscription) and
5403
+ * `addon-pipeline-orchestrator`'s `AudioWindowAccumulator` (which flushes an
5404
+ * f32le window to the analyzer cap, whose `AudioChunkInput` contract is
5405
+ * unchanged and stays f32le). Both bundle the bare `@camstack/types` entry
5406
+ * into their own dist (`self-contained` externals), so this travels with a
5407
+ * `camstack deploy` and needs no published server.
5408
+ *
5409
+ * A second μ-law table anywhere else is the defect this module exists to
5410
+ * prevent. (`stream-broker.ts`'s `mulawToPcm` / `alawToPcm` are the ENCODE
5411
+ * direction for the WebRTC egress — a different transform, not a copy.)
5412
+ *
5413
+ * ## Absent means f32le
5414
+ *
5415
+ * `format` is optional on the wire and its absence means `f32le` — today's
5416
+ * bytes, byte for byte. A peer that never heard of the field is served what it
5417
+ * has always been served, because the broker only emits a coded window to a
5418
+ * subscription that DECLARED it accepts one (`AudioSubscribeOptions.accept`).
5419
+ * That is the D448 `rawForward` negotiation, and it is what makes this
5420
+ * deployable one addon at a time across three nodes.
5421
+ */
5422
+ /** Every byte format the audio chunk plane can carry. `f32le` is the default. */
5423
+ var AUDIO_CHUNK_FORMATS = [
5424
+ "f32le",
5425
+ "pcmu",
5426
+ "pcma"
5427
+ ];
5428
+ /**
5429
+ * Build the μ-law decode table (ITU-T G.711). Each of the 256 byte values maps
5430
+ * to a 16-bit PCM sample, normalised to [-1.0, 1.0] for f32le output.
5431
+ *
5432
+ * Moved here verbatim from `audio-rtp-decoder.ts`, which no longer decodes:
5433
+ * it buffers the coded bytes and the plane's consumers expand.
5434
+ */
5435
+ function buildUlawTable() {
5436
+ const table = new Float32Array(256);
5437
+ for (let i = 0; i < 256; i++) {
5438
+ const complemented = ~i & 255;
5439
+ const sign = (complemented & 128) !== 0 ? -1 : 1;
5440
+ const exponent = complemented >> 4 & 7;
5441
+ table[i] = sign * ((8 * (complemented & 15) + 132 << exponent) - 132) / 32768;
5442
+ }
5443
+ return table;
5444
+ }
5445
+ /** Build the A-law decode table (ITU-T G.711). */
5446
+ function buildAlawTable() {
5447
+ const table = new Float32Array(256);
5448
+ for (let i = 0; i < 256; i++) {
5449
+ const xored = i ^ 85;
5450
+ const sign = (xored & 128) !== 0 ? 1 : -1;
5451
+ const exponent = xored >> 4 & 7;
5452
+ const mantissa = xored & 15;
5453
+ table[i] = sign * (exponent === 0 ? 16 * mantissa + 8 : 16 * mantissa + 264 << exponent - 1) / 32768;
5454
+ }
5455
+ return table;
5456
+ }
5457
+ buildUlawTable();
5458
+ buildAlawTable();
5379
5459
  Object.fromEntries([
5380
5460
  {
5381
5461
  id: "overview",
@@ -6695,11 +6775,20 @@ var SubscribeFramesResultSchema = object({
6695
6775
  * (the wire-serialisable supertype of `Buffer`) to match `DecodedFrameSchema`
6696
6776
  * / `EncodedPacketSchema`'s precedent; a `Buffer` is assignable to it.
6697
6777
  */
6778
+ var AudioChunkFormatSchema = _enum(AUDIO_CHUNK_FORMATS);
6698
6779
  var DecodedAudioChunkSchema = object({
6699
6780
  data: _instanceof(Uint8Array),
6700
6781
  sampleRate: number().int().positive(),
6701
6782
  channels: number().int().positive(),
6702
- timestamp: number()
6783
+ timestamp: number(),
6784
+ /**
6785
+ * Byte format of `data`. ABSENT MEANS `f32le` — today's bytes, byte for
6786
+ * byte, for any peer that never heard of this field. A coded window
6787
+ * (`pcmu` / `pcma`, one byte per sample) is only ever emitted to a
6788
+ * subscription that DECLARED it accepts one, so absence can never mean
6789
+ * "coded bytes a consumer will read as floats" (D455).
6790
+ */
6791
+ format: AudioChunkFormatSchema.optional()
6703
6792
  });
6704
6793
  /**
6705
6794
  * Input for `stream-broker.subscribeAudioChunks` (Phase 5 / D9). The
@@ -6711,7 +6800,18 @@ var DecodedAudioChunkSchema = object({
6711
6800
  var SubscribeAudioChunksInputSchema = object({
6712
6801
  brokerId: string(),
6713
6802
  /** Short caller-identity tag (`audio-analyzer`, …) for `listClients`. */
6714
- tag: string().optional()
6803
+ tag: string().optional(),
6804
+ /**
6805
+ * Byte formats this subscriber can READ, best first. The broker serves the
6806
+ * chunk's own format when it is in this list and expands to `f32le`
6807
+ * otherwise, so a subscriber is never handed bytes it cannot interpret.
6808
+ *
6809
+ * Absent (or without the source format) means `f32le` — the behaviour every
6810
+ * subscriber had before D455, unchanged. This is the negotiation half of
6811
+ * the source-bytes lever: it is what lets the broker and its consumers
6812
+ * deploy one at a time across three nodes.
6813
+ */
6814
+ accept: array(AudioChunkFormatSchema).readonly().optional()
6715
6815
  });
6716
6816
  /** Result of `stream-broker.subscribeAudioChunks`. */
6717
6817
  var SubscribeAudioChunksResultSchema = object({
@@ -7783,7 +7883,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7783
7883
  * still gives the event loop a chance to drain — useful for breaking
7784
7884
  * up tight async loops without changing call-site semantics.
7785
7885
  */
7786
- function sleep$1(ms) {
7886
+ function sleep(ms) {
7787
7887
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7788
7888
  }
7789
7889
  //#endregion
@@ -11536,6 +11636,51 @@ var AudioAnalysisSettingsSchema = object({
11536
11636
  minConfidence: number().min(0).max(1).default(.3),
11537
11637
  allowedClasses: array(string()).default([])
11538
11638
  });
11639
+ /**
11640
+ * `attachDevice` — the analyzer PULLS a camera's audio from the broker (D461).
11641
+ *
11642
+ * Until D461 the orchestrator drained the broker's chunk plane, accumulated
11643
+ * ~1 s windows and pushed them back out as `analyseChunk`. It neither produced
11644
+ * nor consumed the audio: the PCM crossed hub-main twice for a process that
11645
+ * only buffered it. `attachDevice` inverts the direction — the analyzer opens
11646
+ * its own `subscribeAudioChunks` against the broker and the subscriber IS the
11647
+ * decoder, so the coded G.711 bytes D455 put on the plane stay coded all the
11648
+ * way to the one expansion that feeds the model.
11649
+ *
11650
+ * The orchestrator still owns the POLICY (the `audioMode` gate, the on-motion
11651
+ * window, the per-device node assignment, the settings read) and therefore
11652
+ * still owns the attach/detach pair. It no longer owns the bytes.
11653
+ */
11654
+ var AudioAttachDeviceInputSchema = object({
11655
+ deviceId: number(),
11656
+ /** Broker id (`<deviceId>/<camStreamId>`) carrying this camera's audio. */
11657
+ brokerId: string(),
11658
+ /**
11659
+ * `clusterRoles.ingestNode` — the node whose broker owns the source dial.
11660
+ * Every `streamBroker` call the attachment makes is pinned to it, exactly as
11661
+ * the orchestrator's poller pinned them before the move.
11662
+ */
11663
+ ingestNodeId: string(),
11664
+ /**
11665
+ * Resolved once by the orchestrator at attach time, exactly as it was read
11666
+ * once per subscription before D461. The analyzer does NOT re-resolve per
11667
+ * window: a settings change re-attaches, which is what always happened.
11668
+ */
11669
+ settings: AudioAnalysisSettingsSchema
11670
+ });
11671
+ var AudioAttachDeviceResultSchema = object({
11672
+ /** False only when the analyzer is shutting down and refused to attach. */
11673
+ attached: boolean(),
11674
+ /**
11675
+ * True when the attachment replaced a live one for the same device. An
11676
+ * attach is idempotent by REPLACEMENT — two pollers on one camera would
11677
+ * double the broker's fanout and neither would know about the other.
11678
+ */
11679
+ replaced: boolean()
11680
+ });
11681
+ var AudioDetachDeviceResultSchema = object({
11682
+ /** False when no attachment existed — detach is idempotent. */
11683
+ detached: boolean() });
11539
11684
  var AudioClassificationResultSchema = object({
11540
11685
  labels: array(AudioClassificationLabelSchema).readonly(),
11541
11686
  rawLabels: array(AudioClassificationLabelSchema).readonly().optional(),
@@ -11544,7 +11689,7 @@ var AudioClassificationResultSchema = object({
11544
11689
  method(object({
11545
11690
  chunk: AudioChunkInputSchema,
11546
11691
  settings: AudioAnalysisSettingsSchema
11547
- }), AudioAnalysisResultSchema.nullable(), { kind: "mutation" }), method(AudioChunkInputSchema, AudioClassificationResultSchema, { timeoutMs: 3e4 }), method(_void(), boolean()), method(_void(), _void(), { kind: "mutation" }), method(_void(), object({ backend: string() }), {
11692
+ }), 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() }), {
11548
11693
  kind: "mutation",
11549
11694
  auth: "admin"
11550
11695
  });
@@ -32355,12 +32500,24 @@ Object.freeze({
32355
32500
  addonId: null,
32356
32501
  access: "create"
32357
32502
  },
32503
+ "audioAnalyzer.attachDevice": {
32504
+ capName: "audio-analyzer",
32505
+ capScope: "system",
32506
+ addonId: null,
32507
+ access: "create"
32508
+ },
32358
32509
  "audioAnalyzer.classify": {
32359
32510
  capName: "audio-analyzer",
32360
32511
  capScope: "system",
32361
32512
  addonId: null,
32362
32513
  access: "view"
32363
32514
  },
32515
+ "audioAnalyzer.detachDevice": {
32516
+ capName: "audio-analyzer",
32517
+ capScope: "system",
32518
+ addonId: null,
32519
+ access: "create"
32520
+ },
32364
32521
  "audioAnalyzer.dispose": {
32365
32522
  capName: "audio-analyzer",
32366
32523
  capScope: "system",
@@ -38204,11 +38361,21 @@ Object.freeze({
38204
38361
  form: "single",
38205
38362
  optional: false
38206
38363
  }],
38364
+ "audioAnalyzer.attachDevice": [{
38365
+ name: "deviceId",
38366
+ form: "single",
38367
+ optional: false
38368
+ }],
38207
38369
  "audioAnalyzer.classify": [{
38208
38370
  name: "deviceId",
38209
38371
  form: "single",
38210
38372
  optional: true
38211
38373
  }],
38374
+ "audioAnalyzer.detachDevice": [{
38375
+ name: "deviceId",
38376
+ form: "single",
38377
+ optional: false
38378
+ }],
38212
38379
  "audioMetrics.getCurrentSnapshot": [{
38213
38380
  name: "deviceId",
38214
38381
  form: "single",
@@ -40060,6 +40227,52 @@ Object.freeze({
40060
40227
  "network-access": "ingress",
40061
40228
  "smtp-provider": "email"
40062
40229
  });
40230
+ var G711_SCALE_CORRECTION_DB = {
40231
+ PCMU: 20 * Math.log10(4),
40232
+ PCMA: 20 * Math.log10(8)
40233
+ };
40234
+ /**
40235
+ * Restate a dBFS number that was MEASURED through the pre-epoch decoder as the
40236
+ * same intent on the ITU-T scale (D460).
40237
+ *
40238
+ * ## When this applies, and when it is the wrong thing to reach for
40239
+ *
40240
+ * An absolute-dBFS number in this repo is one of two things, and only one of
40241
+ * them converts:
40242
+ *
40243
+ * - **A statement about the scale** — "-55 dBFS is near silence", "-25 dBFS
40244
+ * is loud". It was true on the ITU-T scale before the epoch and it is true
40245
+ * after. The defect was never in the number; it was that 19 of this hub's
40246
+ * 25 cameras did not obey it. Converting such a number takes something
40247
+ * correct and makes it wrong, in order to preserve a bug.
40248
+ * - **A measurement taken through the old decoder** — a value someone read
40249
+ * off a meter that under-reported by exactly 4× (PCMU) or 8× (PCMA). It
40250
+ * describes a sound that was really {@link G711_SCALE_CORRECTION_DB} dB
40251
+ * louder. That is what this function is for.
40252
+ *
40253
+ * Telling the two apart is a question about PROVENANCE, not about arithmetic,
40254
+ * and it cannot be answered from the number. It is answered by the comment the
40255
+ * author left — which is why `scripts/check-dbfs-era.mts` makes leaving one
40256
+ * mandatory.
40257
+ *
40258
+ * ## Why a function and not a typed-in number
40259
+ *
40260
+ * `-55 + 12.04` written into a source file is, six months later, completely
40261
+ * indistinguishable from a threshold somebody simply preferred. Calling this
40262
+ * keeps the derivation, the law, and the original measurement all visible at
40263
+ * the call site, so a future reader can disagree with the *premise* instead of
40264
+ * having to reverse-engineer the sum.
40265
+ *
40266
+ * **This is not a runtime gain.** It converts an authored CONSTANT once, where
40267
+ * it is declared. It must never be applied to a live sample or a stored
40268
+ * `AudioEvent.dbfs`: the decoder is correct now, and a second authority
40269
+ * adjusting numbers the decoder already got right is the original defect with
40270
+ * an extra place to argue with (D459).
40271
+ */
40272
+ function ituDbfsFromPreEpoch(law, authoredDbfs) {
40273
+ return authoredDbfs + G711_SCALE_CORRECTION_DB[law];
40274
+ }
40275
+ Math.round(ituDbfsFromPreEpoch("PCMU", -55));
40063
40276
  /** Schema defaults — an untouched sub-field must author exactly these. */
40064
40277
  var NC_AUDIO_DEFAULTS = {
40065
40278
  hitPercent: 60,
@@ -40771,248 +40984,6 @@ function deviceBackendToFormat(backend) {
40771
40984
  return DEVICE_BACKEND_TO_FORMAT[backend] ?? "onnx";
40772
40985
  }
40773
40986
  //#endregion
40774
- //#region src/audio-chunk-poller.ts
40775
- /**
40776
- * `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
40777
- * plane (Phase 5 / D9).
40778
- *
40779
- * Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
40780
- * path. A live callback cannot cross a process boundary; once the `pipeline`
40781
- * group is dissolved (Task 8) the orchestrator runs in a different process
40782
- * from the broker, so audio delivery must go over tRPC.
40783
- *
40784
- * The consumer:
40785
- *
40786
- * 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC — the broker
40787
- * registers a per-subscription bounded FIFO queue and returns a
40788
- * `subscriptionId`;
40789
- * 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
40790
- * interval, draining `DecodedAudioChunk`s in FIFO arrival order;
40791
- * 3. feeds each chunk to its downstream audio logic;
40792
- * 4. on teardown, `unsubscribeAudioChunks`.
40793
- *
40794
- * Audio is not latency-critical like video, and chunks arrive only ~every
40795
- * 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
40796
- * a small per-poll burst keeps latency low without busy-spinning. The
40797
- * broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
40798
- * loses a chunk.
40799
- *
40800
- * Boot-race tolerance: the broker for a given camStream may not be registered
40801
- * yet when the orchestrator wires the subscription (provider addons publish
40802
- * their cameraStreams asynchronously after their probe completes).
40803
- * `subscribeAudioChunks` retries with exponential backoff (capped at
40804
- * `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
40805
- * teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
40806
- * shape so video and audio plumbing self-heal identically.
40807
- */
40808
- /** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
40809
- var POLL_INTERVAL_MS$1 = 200;
40810
- /** How many chunks to drain per poll — a small burst absorbs jitter. */
40811
- var PULL_MAX_COUNT = 8;
40812
- /**
40813
- * Consecutive pull failures before we attempt to re-subscribe. A single failed
40814
- * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
40815
- * sustained failure means the broker child restarted and dropped our
40816
- * subscription, so we re-establish it.
40817
- */
40818
- var RESUBSCRIBE_AFTER_FAILURES = 2;
40819
- /** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
40820
- var RESUBSCRIBE_THROTTLE_TICKS = 5;
40821
- /** First subscribe-retry delay, doubled on every subsequent failure. */
40822
- var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
40823
- /**
40824
- * Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
40825
- * enough to recover within a single reconcile of the orchestrator and slow
40826
- * enough that a misconfigured camStream costs ~12 op/min, not 5/s.
40827
- */
40828
- var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
40829
- /**
40830
- * Attempts after which a still-failing subscribe escalates from the fast 5 s
40831
- * ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
40832
- * minute of fast retries — plenty for the boot races the 5 s ceiling exists
40833
- * for. A broker that is STILL absent after that is a long-lived condition
40834
- * (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
40835
- * to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
40836
- * churn. The slow loop stays alive so audio still recovers automatically
40837
- * (≤60 s) once the camera is re-enabled.
40838
- */
40839
- var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
40840
- var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
40841
- /**
40842
- * Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
40843
- *
40844
- * Always resolves to a teardown closure — when the broker is not yet
40845
- * registered the closure cancels the ongoing retry loop; when polling is
40846
- * active it stops the loop and releases the broker subscription. Mirrors
40847
- * `startFrameHandlePoller` so video and audio recover identically.
40848
- */
40849
- function startAudioChunkPoller(options) {
40850
- const lifecycle = {
40851
- stopped: false,
40852
- retryTimer: void 0,
40853
- pollTimer: void 0,
40854
- activeSubscriptionId: null
40855
- };
40856
- const teardown = () => {
40857
- if (lifecycle.stopped) return;
40858
- lifecycle.stopped = true;
40859
- if (lifecycle.retryTimer) {
40860
- clearTimeout(lifecycle.retryTimer);
40861
- lifecycle.retryTimer = void 0;
40862
- }
40863
- if (lifecycle.pollTimer) {
40864
- clearTimeout(lifecycle.pollTimer);
40865
- lifecycle.pollTimer = void 0;
40866
- }
40867
- const subId = lifecycle.activeSubscriptionId;
40868
- if (subId) {
40869
- lifecycle.activeSubscriptionId = null;
40870
- options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
40871
- options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
40872
- brokerId: options.brokerId,
40873
- subscriptionId: subId,
40874
- error: errMsg(err)
40875
- } });
40876
- });
40877
- }
40878
- };
40879
- subscribeWithRetry(options, lifecycle);
40880
- return teardown;
40881
- }
40882
- /**
40883
- * Run the subscribe → poll handshake with exponential backoff on subscribe
40884
- * failures. Resolves once the subscription is acquired (and the poll loop has
40885
- * been started) or once `lifecycle.stopped` flips, whichever comes first.
40886
- */
40887
- async function subscribeWithRetry(options, lifecycle) {
40888
- const { api, brokerId, tag, ownerNodeId, logger } = options;
40889
- const pin = nodePin(ownerNodeId);
40890
- let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
40891
- let attempt = 0;
40892
- while (!lifecycle.stopped) {
40893
- attempt += 1;
40894
- try {
40895
- const result = await api.streamBroker.subscribeAudioChunks.mutate({
40896
- brokerId,
40897
- tag
40898
- }, pin);
40899
- if (lifecycle.stopped) {
40900
- await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }, pin).catch((err) => {
40901
- logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
40902
- brokerId,
40903
- subscriptionId: result.subscriptionId,
40904
- error: errMsg(err)
40905
- } });
40906
- });
40907
- return;
40908
- }
40909
- if (attempt > 1) logger.info("audio-chunk poller: subscribeAudioChunks recovered", { meta: {
40910
- brokerId,
40911
- tag,
40912
- attempt
40913
- } });
40914
- lifecycle.activeSubscriptionId = result.subscriptionId;
40915
- startPolling(options, lifecycle);
40916
- return;
40917
- } catch (err) {
40918
- if (lifecycle.stopped) return;
40919
- if (attempt === 1) logger.warn("audio-chunk poller: subscribeAudioChunks failed, retrying", { meta: {
40920
- brokerId,
40921
- tag,
40922
- error: errMsg(err),
40923
- nextRetryInMs: backoffMs
40924
- } });
40925
- else logger.debug("audio-chunk poller: subscribeAudioChunks still failing", { meta: {
40926
- brokerId,
40927
- tag,
40928
- attempt,
40929
- error: errMsg(err),
40930
- nextRetryInMs: backoffMs
40931
- } });
40932
- await sleep(backoffMs, lifecycle);
40933
- backoffMs = Math.min(attempt >= PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS ? LONG_SUBSCRIBE_RETRY_BACKOFF_MS : MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
40934
- }
40935
- }
40936
- }
40937
- /**
40938
- * Drive the steady-state poll loop. Mid-stream `pullAudioChunks` failures
40939
- * trigger a fresh `subscribeAudioChunks` via the recovery branch — covers
40940
- * the broker child restart case where our `subscriptionId` is silently
40941
- * disowned.
40942
- */
40943
- function startPolling(options, lifecycle) {
40944
- const { api, brokerId, tag, ownerNodeId, onChunk, logger } = options;
40945
- const pin = nodePin(ownerNodeId);
40946
- let consecutiveFailures = 0;
40947
- const resubscribe = async () => {
40948
- try {
40949
- const result = await api.streamBroker.subscribeAudioChunks.mutate({
40950
- brokerId,
40951
- tag
40952
- }, pin);
40953
- lifecycle.activeSubscriptionId = result.subscriptionId;
40954
- logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
40955
- brokerId,
40956
- tag,
40957
- subscriptionId: result.subscriptionId,
40958
- afterFailures: consecutiveFailures
40959
- } });
40960
- return true;
40961
- } catch {
40962
- return false;
40963
- }
40964
- };
40965
- const tick = async () => {
40966
- if (lifecycle.stopped) return;
40967
- const subId = lifecycle.activeSubscriptionId;
40968
- if (!subId) return;
40969
- try {
40970
- const chunks = await api.streamBroker.pullAudioChunks.query({
40971
- subscriptionId: subId,
40972
- maxCount: PULL_MAX_COUNT
40973
- }, pin);
40974
- if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
40975
- brokerId,
40976
- subscriptionId: subId
40977
- } });
40978
- consecutiveFailures = 0;
40979
- for (const chunk of chunks) {
40980
- if (lifecycle.stopped) break;
40981
- await onChunk(chunk);
40982
- }
40983
- } catch (err) {
40984
- consecutiveFailures += 1;
40985
- if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
40986
- brokerId,
40987
- subscriptionId: subId,
40988
- error: errMsg(err)
40989
- } });
40990
- if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
40991
- }
40992
- if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), POLL_INTERVAL_MS$1);
40993
- };
40994
- tick();
40995
- }
40996
- /**
40997
- * Cancellable sleep — wakes early when `lifecycle.stopped` flips. We
40998
- * keep a local wrapper around the shared {@link sleep} helper because
40999
- * the lifecycle tracks the active retry timer for `teardown()` to
41000
- * clear; pure `sleep()` would leak the timer if teardown fired while
41001
- * we were waiting.
41002
- */
41003
- function sleep(ms, lifecycle) {
41004
- return new Promise((resolve) => {
41005
- if (lifecycle.stopped) {
41006
- resolve();
41007
- return;
41008
- }
41009
- lifecycle.retryTimer = setTimeout(() => {
41010
- lifecycle.retryTimer = void 0;
41011
- resolve();
41012
- }, ms);
41013
- });
41014
- }
41015
- //#endregion
41016
40987
  //#region src/audio-load-balancer.ts
41017
40988
  function balanceAudio(input) {
41018
40989
  if (input.nodes.length === 0) return null;
@@ -41029,278 +41000,6 @@ function balanceAudio(input) {
41029
41000
  };
41030
41001
  }
41031
41002
  //#endregion
41032
- //#region src/orchestrator-types.ts
41033
- var PHASE_MODE_VALUES = new Set([
41034
- "disabled",
41035
- "always-on",
41036
- "on-motion"
41037
- ]);
41038
- function isPipelinePhaseMode(v) {
41039
- return PHASE_MODE_VALUES.has(v);
41040
- }
41041
- /**
41042
- * Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
41043
- * that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
41044
- * `reconcileDispatch` is additive-only and never revisits these, so a slow
41045
- * safety-net timer + event-driven debounce triggers recover them.
41046
- */
41047
- var PENDING_RETRY_INTERVAL_MS = 6e4;
41048
- /** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
41049
- var PENDING_RETRY_DEBOUNCE_MS = 2e3;
41050
- /**
41051
- * Periodic auto-rebalance sweep. New attaches are already load-balanced at
41052
- * dispatch time; this corrects DRIFT that accumulates over time (uneven
41053
- * detach, a node returning online, a weight change) so the steady-state
41054
- * distribution tracks the per-node weights. Only runs with ≥2 enabled detect
41055
- * nodes, and migrates under hysteresis so a balanced cluster is a no-op.
41056
- */
41057
- var AUTO_REBALANCE_INTERVAL_MS = 6e4;
41058
- /**
41059
- * Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
41060
- * migrate a camera only when its target node is at least this much less loaded
41061
- * than its current node. > 1 so equalizing a single-camera gap (which would
41062
- * only reverse the imbalance) is skipped — prevents periodic churn.
41063
- */
41064
- var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
41065
- var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
41066
- /**
41067
- * Device-details keys routed through the orchestrator's pipeline
41068
- * settings writer instead of the device orchestration store. The
41069
- * `cameraPipeline` key carries the full `CameraPipelineConfig`
41070
- * emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
41071
- */
41072
- var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
41073
- var DEFAULT_FAILOVER_POLICY = {
41074
- onDisconnect: "migrate",
41075
- pinnedOnDisconnect: "leave-pinned",
41076
- onReconnect: "restore"
41077
- };
41078
- /**
41079
- * Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
41080
- *
41081
- * The orchestrator's cap surface is the contract for all runtime traffic
41082
- * (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
41083
- * catalog is reserved for read-only diagnostics that are intentionally
41084
- * outside the cap — they expose internal state (balancer caches, enabledNodes
41085
- * set, active detection count) that is useful for admin tooling but does not
41086
- * belong on the capability contract.
41087
- */
41088
- var OrchestratorDiagnosticsSchema = object({
41089
- localNodeId: string(),
41090
- knownRunnerNodes: array(string()),
41091
- cachedAgentLoadNodeIds: array(string()),
41092
- enabledNodes: array(string()),
41093
- enabledDecoderNodes: array(string()),
41094
- enabledAudioNodes: array(string()),
41095
- enabledIngestNodes: array(string()),
41096
- clusterRoles: object({
41097
- ingestNode: string(),
41098
- audioNode: string(),
41099
- motionNode: string()
41100
- }),
41101
- assignedDeviceCount: number().int().min(0),
41102
- cameraConfigCount: number().int().min(0),
41103
- activeDetectionCount: number().int().min(0)
41104
- });
41105
- /**
41106
- * The node-stress long-term-statistics read surface.
41107
- *
41108
- * A custom action rather than a cap method, matching how the orchestrator
41109
- * already serves `dumpState`: this is a hub-local read over a table the hub
41110
- * owns, and it ships with one `camstack deploy` instead of a release train.
41111
- * The MEAN is derived here and returned alongside the addable `sum`/`samples`
41112
- * — a chart wants the first, a re-bucketing caller wants the second, and a
41113
- * stored mean is a field that can disagree with both.
41114
- */
41115
- var NodeStressStatsInputSchema = object({
41116
- /** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
41117
- series: string().optional(),
41118
- /** A node id. Omit for every node. */
41119
- subject: string().optional(),
41120
- /** Inclusive bucket-start bounds, ms. */
41121
- from: number().int().optional(),
41122
- to: number().int().optional(),
41123
- limit: number().int().positive().max(5e3).optional()
41124
- });
41125
- var NodeStressStatsRowSchema = object({
41126
- subject: string(),
41127
- series: string(),
41128
- scope: string(),
41129
- bucketStart: number(),
41130
- samples: number(),
41131
- sum: number(),
41132
- mean: number(),
41133
- min: number(),
41134
- max: number()
41135
- });
41136
- var NodeStressStatsOutputSchema = object({
41137
- rows: array(NodeStressStatsRowSchema).readonly(),
41138
- /** Buckets still accumulating — "is it running" answerable at once, rather
41139
- * than after five minutes of indistinguishable silence. */
41140
- open: array(NodeStressStatsRowSchema).readonly(),
41141
- /** The durable failover history the anti-flap guards read, newest first.
41142
- * Exposed for the same reason the heartbeat exists: "nothing moved" has to
41143
- * be distinguishable from "nothing is watching". */
41144
- moves: array(object({
41145
- deviceId: number(),
41146
- fromNodeId: string(),
41147
- at: number()
41148
- })).readonly()
41149
- });
41150
- var pipelineOrchestratorActions = defineCustomActions({
41151
- dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
41152
- nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
41153
- });
41154
- /**
41155
- * Sentinel returned by `buildDetectionConfig` when the profile-slot READ
41156
- * itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
41157
- * while the stream-broker is (re)starting) — as opposed to `null`, which
41158
- * means "genuinely no assigned slot / not configured". Callers MUST treat
41159
- * this differently from `null`: never stop active detection on a transient
41160
- * read failure (the slots almost certainly still exist), and schedule a retry.
41161
- */
41162
- var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
41163
- //#endregion
41164
- //#region src/audio-window-accumulator.ts
41165
- function sameFormat(a, b) {
41166
- return a.sampleRate === b.sampleRate && a.channels === b.channels;
41167
- }
41168
- var AudioWindowAccumulator = class {
41169
- deviceId;
41170
- onFormat;
41171
- pcmParts = [];
41172
- accumulatedBytes = 0;
41173
- accumulatedMs = 0;
41174
- windowSampleRate = 0;
41175
- windowChannels = 0;
41176
- windowTimestamp = 0;
41177
- windowOpen = false;
41178
- /** Last format seen on this subscription — survives a flush, unlike the window anchor. */
41179
- lastFormat = null;
41180
- constructor(deviceId, onFormat) {
41181
- this.deviceId = deviceId;
41182
- this.onFormat = onFormat;
41183
- }
41184
- /**
41185
- * Append one decoded PCM chunk to the open window. Returns the flushed
41186
- * `AudioChunkInput` once the accumulated duration reaches
41187
- * `AUDIO_WINDOW_TARGET_MS` (and resets for the next window), else `null`
41188
- * (accumulate-only, no flush yet).
41189
- */
41190
- push(chunk) {
41191
- const byteLength = chunk.data.byteLength;
41192
- const bytes = new Uint8Array(byteLength);
41193
- bytes.set(new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, byteLength));
41194
- this.observeFormat({
41195
- sampleRate: chunk.sampleRate,
41196
- channels: chunk.channels
41197
- });
41198
- if (!this.windowOpen) {
41199
- this.windowSampleRate = chunk.sampleRate;
41200
- this.windowChannels = chunk.channels;
41201
- this.windowTimestamp = chunk.timestamp;
41202
- this.windowOpen = true;
41203
- }
41204
- this.pcmParts.push(bytes);
41205
- this.accumulatedBytes += byteLength;
41206
- const channels = chunk.channels > 0 ? chunk.channels : 1;
41207
- const framesPerChannel = byteLength / 4 / channels;
41208
- this.accumulatedMs += framesPerChannel / chunk.sampleRate * 1e3;
41209
- if (this.accumulatedMs < 1e3) return null;
41210
- const windowData = new Uint8Array(this.accumulatedBytes);
41211
- let offset = 0;
41212
- for (const part of this.pcmParts) {
41213
- windowData.set(part, offset);
41214
- offset += part.byteLength;
41215
- }
41216
- const flushSampleRate = this.windowSampleRate;
41217
- const flushChannels = this.windowChannels;
41218
- const flushTimestamp = this.windowTimestamp;
41219
- this.reset();
41220
- return {
41221
- data: windowData,
41222
- sampleRate: flushSampleRate,
41223
- channels: flushChannels,
41224
- timestamp: flushTimestamp,
41225
- deviceId: this.deviceId
41226
- };
41227
- }
41228
- /**
41229
- * Report the first format and every change; a change under an OPEN window
41230
- * drops the partial window so the flushed bytes never mix two rates.
41231
- */
41232
- observeFormat(format) {
41233
- const previous = this.lastFormat;
41234
- if (previous !== null && sameFormat(previous, format)) return;
41235
- const droppedMs = this.windowOpen ? Math.round(this.accumulatedMs) : 0;
41236
- if (this.windowOpen) this.reset();
41237
- this.lastFormat = format;
41238
- this.onFormat?.({
41239
- from: previous,
41240
- to: format,
41241
- droppedMs
41242
- });
41243
- }
41244
- /**
41245
- * Drop any partially-accumulated window. Called on unsubscribe / device
41246
- * audio stop (no trailing flush — a sub-second partial window is not worth
41247
- * a classify during teardown) and internally after every flush.
41248
- */
41249
- reset() {
41250
- this.pcmParts = [];
41251
- this.accumulatedBytes = 0;
41252
- this.accumulatedMs = 0;
41253
- this.windowOpen = false;
41254
- }
41255
- };
41256
- /**
41257
- * Build the canonical `AudioResult` frame from one `analyseChunk` result —
41258
- * extracted verbatim from `subscribeAudioStream`'s result→event-payload
41259
- * mapping. The caller wraps this in the `pipeline.audio-inference-result`
41260
- * event; building the frame is pure and needs no controller state.
41261
- */
41262
- function buildAudioResultFrame(deviceId, result) {
41263
- const windowId = `${deviceId}-${result.timestamp}`;
41264
- const audioDetections = [];
41265
- let idCounter = 0;
41266
- if (result.classification && result.classification.labels.length > 0) for (const label of result.classification.labels) {
41267
- idCounter += 1;
41268
- audioDetections.push({
41269
- id: `a${idCounter}`,
41270
- kind: "audio",
41271
- macroClass: label.className,
41272
- score: label.score,
41273
- labels: [{
41274
- label: label.className,
41275
- score: label.score
41276
- }],
41277
- startMs: 0,
41278
- endMs: 0
41279
- });
41280
- }
41281
- return {
41282
- kind: "audio-window",
41283
- windowId,
41284
- deviceId,
41285
- timestamp: result.timestamp,
41286
- startMs: 0,
41287
- endMs: 0,
41288
- level: {
41289
- rms: result.level.rms,
41290
- dbfs: result.level.dbfs
41291
- },
41292
- detections: audioDetections,
41293
- debug: result.classification ? {
41294
- totalInferenceMs: result.classification.inferenceMs,
41295
- stepTimings: [{
41296
- source: "audio-analyzer",
41297
- ms: result.classification.inferenceMs,
41298
- detectionCount: audioDetections.length
41299
- }]
41300
- } : void 0
41301
- };
41302
- }
41303
- //#endregion
41304
41003
  //#region src/keyed-async-lock.ts
41305
41004
  /**
41306
41005
  * Serializes critical sections per key. Different keys run fully
@@ -41828,73 +41527,41 @@ var AudioSubscriptionController = class {
41828
41527
  isRemote: isRemoteAudio
41829
41528
  }
41830
41529
  });
41831
- const accumulator = new AudioWindowAccumulator(deviceId, (change) => {
41832
- const meta = {
41833
- sampleRate: change.to.sampleRate,
41834
- channels: change.to.channels,
41835
- previous: change.from,
41836
- droppedMs: change.droppedMs
41837
- };
41838
- if (change.from === null) {
41839
- this.deps.logger.info("audio stream delivering", {
41840
- tags: { deviceId },
41841
- meta
41842
- });
41843
- return;
41844
- }
41845
- this.deps.logger.warn("audio stream format changed — partial window dropped", {
41846
- tags: { deviceId },
41847
- meta
41848
- });
41849
- });
41850
- const teardown = startAudioChunkPoller({
41851
- api,
41530
+ const attach = await api.audioAnalyzer.attachDevice.mutate({
41531
+ deviceId,
41852
41532
  brokerId: audioBrokerId,
41853
- tag: "audio-analyzer",
41854
- ownerNodeId: this.deps.ingestNode(),
41855
- logger: this.deps.logger.withTags({ deviceId }),
41856
- onChunk: async (chunk) => {
41857
- this.deps.watchdogNote(deviceId, "audio");
41858
- try {
41859
- const audioChunkInput = accumulator.push(chunk);
41860
- if (!audioChunkInput) return;
41861
- const result = await api.audioAnalyzer.analyseChunk.mutate({
41862
- chunk: audioChunkInput,
41863
- settings,
41864
- ...isRemoteAudio ? { nodeId: audioNodeId } : {}
41865
- });
41866
- if (!result) return;
41867
- const frame = buildAudioResultFrame(deviceId, result);
41868
- this.deps.eventBus.emit({
41869
- id: `audio-inference-${deviceId}-${Date.now()}`,
41870
- timestamp: /* @__PURE__ */ new Date(),
41871
- source: {
41872
- type: "device",
41873
- id: deviceId,
41874
- nodeId: "hub",
41875
- addonId: "pipeline-orchestrator",
41876
- deviceId
41877
- },
41878
- category: EventCategory.PipelineAudioInferenceResult,
41879
- data: {
41880
- deviceId,
41881
- frame,
41882
- nodeId: "hub"
41883
- }
41884
- });
41885
- } catch (err) {
41886
- const msg = errMsg(err);
41887
- this.deps.logger.error("Audio analysis failed", {
41888
- tags: { deviceId },
41889
- meta: { error: msg }
41890
- });
41533
+ ingestNodeId: this.deps.ingestNode(),
41534
+ settings
41535
+ }, nodePin(audioNodeId));
41536
+ if (!attach.attached) {
41537
+ this.deps.logger.warn("audio subscription REFUSED by the analyzer", {
41538
+ tags: { deviceId },
41539
+ meta: {
41540
+ audioNodeId,
41541
+ brokerId: audioBrokerId
41891
41542
  }
41543
+ });
41544
+ return null;
41545
+ }
41546
+ this.deps.logger.info("Audio stream subscribed", {
41547
+ tags: { deviceId },
41548
+ meta: {
41549
+ audioNodeId,
41550
+ brokerId: audioBrokerId,
41551
+ replaced: attach.replaced
41892
41552
  }
41893
41553
  });
41894
- this.deps.logger.info("Audio stream subscribed", { tags: { deviceId } });
41895
41554
  return () => {
41896
- teardown();
41897
- accumulator.reset();
41555
+ api.audioAnalyzer.detachDevice.mutate({ deviceId }, nodePin(audioNodeId)).catch((err) => {
41556
+ this.deps.logger.warn("audio detachDevice failed — the broker lease will reclaim it", {
41557
+ tags: { deviceId },
41558
+ meta: {
41559
+ audioNodeId,
41560
+ brokerId: audioBrokerId,
41561
+ error: errMsg(err)
41562
+ }
41563
+ });
41564
+ });
41898
41565
  };
41899
41566
  }
41900
41567
  /**
@@ -44643,6 +44310,138 @@ var DeviceActivitySource = class {
44643
44310
  }
44644
44311
  };
44645
44312
  //#endregion
44313
+ //#region src/orchestrator-types.ts
44314
+ var PHASE_MODE_VALUES = new Set([
44315
+ "disabled",
44316
+ "always-on",
44317
+ "on-motion"
44318
+ ]);
44319
+ function isPipelinePhaseMode(v) {
44320
+ return PHASE_MODE_VALUES.has(v);
44321
+ }
44322
+ /**
44323
+ * Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
44324
+ * that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
44325
+ * `reconcileDispatch` is additive-only and never revisits these, so a slow
44326
+ * safety-net timer + event-driven debounce triggers recover them.
44327
+ */
44328
+ var PENDING_RETRY_INTERVAL_MS = 6e4;
44329
+ /** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
44330
+ var PENDING_RETRY_DEBOUNCE_MS = 2e3;
44331
+ /**
44332
+ * Periodic auto-rebalance sweep. New attaches are already load-balanced at
44333
+ * dispatch time; this corrects DRIFT that accumulates over time (uneven
44334
+ * detach, a node returning online, a weight change) so the steady-state
44335
+ * distribution tracks the per-node weights. Only runs with ≥2 enabled detect
44336
+ * nodes, and migrates under hysteresis so a balanced cluster is a no-op.
44337
+ */
44338
+ var AUTO_REBALANCE_INTERVAL_MS = 6e4;
44339
+ /**
44340
+ * Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
44341
+ * migrate a camera only when its target node is at least this much less loaded
44342
+ * than its current node. > 1 so equalizing a single-camera gap (which would
44343
+ * only reverse the imbalance) is skipped — prevents periodic churn.
44344
+ */
44345
+ var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
44346
+ var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
44347
+ /**
44348
+ * Device-details keys routed through the orchestrator's pipeline
44349
+ * settings writer instead of the device orchestration store. The
44350
+ * `cameraPipeline` key carries the full `CameraPipelineConfig`
44351
+ * emitted by the `pipeline-editor` ConfigField (Phase 6 Option B).
44352
+ */
44353
+ var PIPELINE_PATCH_KEYS = ["cameraPipeline"];
44354
+ var DEFAULT_FAILOVER_POLICY = {
44355
+ onDisconnect: "migrate",
44356
+ pinnedOnDisconnect: "leave-pinned",
44357
+ onReconnect: "restore"
44358
+ };
44359
+ /**
44360
+ * Custom-action catalog exposed through `api.addons.custom` (Task 9.1 PoC).
44361
+ *
44362
+ * The orchestrator's cap surface is the contract for all runtime traffic
44363
+ * (assignCamera / unassignCamera / rebalance / getGlobalMetrics etc). This
44364
+ * catalog is reserved for read-only diagnostics that are intentionally
44365
+ * outside the cap — they expose internal state (balancer caches, enabledNodes
44366
+ * set, active detection count) that is useful for admin tooling but does not
44367
+ * belong on the capability contract.
44368
+ */
44369
+ var OrchestratorDiagnosticsSchema = object({
44370
+ localNodeId: string(),
44371
+ knownRunnerNodes: array(string()),
44372
+ cachedAgentLoadNodeIds: array(string()),
44373
+ enabledNodes: array(string()),
44374
+ enabledDecoderNodes: array(string()),
44375
+ enabledAudioNodes: array(string()),
44376
+ enabledIngestNodes: array(string()),
44377
+ clusterRoles: object({
44378
+ ingestNode: string(),
44379
+ audioNode: string(),
44380
+ motionNode: string()
44381
+ }),
44382
+ assignedDeviceCount: number().int().min(0),
44383
+ cameraConfigCount: number().int().min(0),
44384
+ activeDetectionCount: number().int().min(0)
44385
+ });
44386
+ /**
44387
+ * The node-stress long-term-statistics read surface.
44388
+ *
44389
+ * A custom action rather than a cap method, matching how the orchestrator
44390
+ * already serves `dumpState`: this is a hub-local read over a table the hub
44391
+ * owns, and it ships with one `camstack deploy` instead of a release train.
44392
+ * The MEAN is derived here and returned alongside the addable `sum`/`samples`
44393
+ * — a chart wants the first, a re-bucketing caller wants the second, and a
44394
+ * stored mean is a field that can disagree with both.
44395
+ */
44396
+ var NodeStressStatsInputSchema = object({
44397
+ /** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
44398
+ series: string().optional(),
44399
+ /** A node id. Omit for every node. */
44400
+ subject: string().optional(),
44401
+ /** Inclusive bucket-start bounds, ms. */
44402
+ from: number().int().optional(),
44403
+ to: number().int().optional(),
44404
+ limit: number().int().positive().max(5e3).optional()
44405
+ });
44406
+ var NodeStressStatsRowSchema = object({
44407
+ subject: string(),
44408
+ series: string(),
44409
+ scope: string(),
44410
+ bucketStart: number(),
44411
+ samples: number(),
44412
+ sum: number(),
44413
+ mean: number(),
44414
+ min: number(),
44415
+ max: number()
44416
+ });
44417
+ var NodeStressStatsOutputSchema = object({
44418
+ rows: array(NodeStressStatsRowSchema).readonly(),
44419
+ /** Buckets still accumulating — "is it running" answerable at once, rather
44420
+ * than after five minutes of indistinguishable silence. */
44421
+ open: array(NodeStressStatsRowSchema).readonly(),
44422
+ /** The durable failover history the anti-flap guards read, newest first.
44423
+ * Exposed for the same reason the heartbeat exists: "nothing moved" has to
44424
+ * be distinguishable from "nothing is watching". */
44425
+ moves: array(object({
44426
+ deviceId: number(),
44427
+ fromNodeId: string(),
44428
+ at: number()
44429
+ })).readonly()
44430
+ });
44431
+ var pipelineOrchestratorActions = defineCustomActions({
44432
+ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
44433
+ nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
44434
+ });
44435
+ /**
44436
+ * Sentinel returned by `buildDetectionConfig` when the profile-slot READ
44437
+ * itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
44438
+ * while the stream-broker is (re)starting) — as opposed to `null`, which
44439
+ * means "genuinely no assigned slot / not configured". Callers MUST treat
44440
+ * this differently from `null`: never stop active detection on a transient
44441
+ * read failure (the slots almost certainly still exist), and schedule a retry.
44442
+ */
44443
+ var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
44444
+ //#endregion
44646
44445
  //#region src/device-detection-settings.ts
44647
44446
  /** Read a required string leaf out of the hydrated `flat` schema values. */
44648
44447
  function mustString(flat, deviceId, key) {
@@ -48917,6 +48716,10 @@ function wireOrchestratorSubscriptions(deps) {
48917
48716
  const deviceId = event.source.deviceId;
48918
48717
  if (typeof deviceId === "number") deps.noteWatchdogSignal(deviceId, "motion");
48919
48718
  });
48719
+ const unsubAudioWatchdog = deps.eventBus.subscribe({ category: EventCategory.PipelineAudioInferenceResult }, (event) => {
48720
+ if (!isEvent(event, EventCategory.PipelineAudioInferenceResult)) return;
48721
+ deps.noteWatchdogSignal(event.data.deviceId, "audio");
48722
+ });
48920
48723
  const activitySource = new DeviceActivitySource({
48921
48724
  logger: deps.logger,
48922
48725
  emitMotion: (deviceId, detected, timestamp) => {
@@ -48972,6 +48775,7 @@ function wireOrchestratorSubscriptions(deps) {
48972
48775
  unsubSessionMotion();
48973
48776
  unsubFrameTracked();
48974
48777
  unsubMotionAnalysis();
48778
+ unsubAudioWatchdog();
48975
48779
  unsubDeviceActivity();
48976
48780
  };
48977
48781
  }
@@ -50082,7 +49886,7 @@ var PipelineSettingsStore = class PipelineSettingsStore {
50082
49886
  nodeId
50083
49887
  }, { timeoutMs: PipelineSettingsStore.EXECUTOR_RECONCILE_ROUND_MS }).then(() => true, () => false);
50084
49888
  if (this.disposed) break;
50085
- if (readyPerRegistry) await sleep$1(PipelineSettingsStore.EXECUTOR_READY_PROBE_PACE_MS);
49889
+ if (readyPerRegistry) await sleep(PipelineSettingsStore.EXECUTOR_READY_PROBE_PACE_MS);
50086
49890
  }
50087
49891
  return null;
50088
49892
  }
@@ -50110,13 +49914,13 @@ var PipelineSettingsStore = class PipelineSettingsStore {
50110
49914
  let agent = (await this.readAgentSettingsMap())[nodeId];
50111
49915
  if (!agent) {
50112
49916
  if (!await this.seedAgentSettingsFromCatalog(nodeId)) {
50113
- await sleep$1(2e3);
49917
+ await sleep(2e3);
50114
49918
  continue;
50115
49919
  }
50116
49920
  agent = (await this.readAgentSettingsMap())[nodeId];
50117
49921
  }
50118
49922
  if (!agent) {
50119
- await sleep$1(2e3);
49923
+ await sleep(2e3);
50120
49924
  continue;
50121
49925
  }
50122
49926
  return {
@@ -53141,7 +52945,6 @@ async function buildOrchestratorControllers(deps) {
53141
52945
  eventBus: deps.ctx().eventBus,
53142
52946
  logger: deps.ctx().logger,
53143
52947
  readDeviceStore: async (deviceId) => await deps.ctxIfReady()?.settings?.readDeviceStore(deviceId) ?? {},
53144
- watchdogNote: (deviceId, stage) => pipelineWatchdog?.noteSignal(deviceId, stage),
53145
52948
  probeAudioTrack: (deviceId, camStreamId) => resolveAudioTrackProbe(deviceId, camStreamId),
53146
52949
  ingestNode: () => topology.clusterRoles.ingestNode,
53147
52950
  localNodeId: () => localNodeId,