@camstack/addon-pipeline 1.2.220 → 1.2.221

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/audio-analyzer/index.js +614 -4
  2. package/dist/audio-analyzer/index.mjs +614 -4
  3. package/dist/{default-detection-model-x6oNMBDH.js → default-detection-model-B1NT1A38.js} +1 -1
  4. package/dist/{default-detection-model-DBNEc3iw.mjs → default-detection-model-DkVFgubU.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-ByPsWlTa.js → dist-ATDfcxgL.js} +280 -2
  8. package/dist/{dist-ClihWGOZ.mjs → dist-CEFC3PkP.mjs} +263 -3
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-CNPnaux4.js → node-C5jnTgpv.js} +1 -1
  12. package/dist/{node-DiaTmz84.mjs → node-DR3fHqUq.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-9KqRtUHt.js → process-memory-DQbCPnMA.js} +1 -1
  16. package/dist/{process-memory-D4t8hpJd.mjs → process-memory-DqYj5IAb.mjs} +1 -1
  17. package/dist/recorder/index.js +12 -6
  18. package/dist/recorder/index.mjs +12 -6
  19. package/dist/{segment-demux-js-CFFXVae4.mjs → segment-demux-js-CddDwD69.mjs} +1 -1
  20. package/dist/{segment-demux-js-CZefXDLx.js → segment-demux-js-o9KBAz9p.js} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CtdpN7Sz.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D5XnLZWR.mjs} +3 -3
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CyMYDKfM.mjs +26 -0
  24. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DICVQkNi.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CKUGpDns.mjs} +1 -1
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-McoUiljf.mjs → hostInit-C-VdcJS2.mjs} +3 -3
  28. package/dist/stream-broker/index.js +222 -102
  29. package/dist/stream-broker/index.mjs +222 -102
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-7hqrrd88.mjs +0 -26
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-ByPsWlTa.js");
7
- const require_process_memory = require("../process-memory-9KqRtUHt.js");
6
+ const require_dist = require("../dist-ATDfcxgL.js");
7
+ const require_process_memory = require("../process-memory-DQbCPnMA.js");
8
8
  let node_fs = require("node:fs");
9
9
  node_fs = require_chunk.__toESM(node_fs);
10
10
  let node_path = require("node:path");
@@ -397,6 +397,558 @@ function resampleMono16k(chunk) {
397
397
  }
398
398
  return out;
399
399
  }
400
+ /** How many chunks to drain per poll — a small burst absorbs jitter. */
401
+ var PULL_MAX_COUNT = 8;
402
+ /**
403
+ * Consecutive pull failures before we attempt to re-subscribe. A single failed
404
+ * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
405
+ * sustained failure means the broker child restarted and dropped our
406
+ * subscription, so we re-establish it.
407
+ */
408
+ var RESUBSCRIBE_AFTER_FAILURES = 2;
409
+ /** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
410
+ var RESUBSCRIBE_THROTTLE_TICKS = 5;
411
+ /** First subscribe-retry delay, doubled on every subsequent failure. */
412
+ var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
413
+ /**
414
+ * Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
415
+ * enough to recover within a single reconcile of the orchestrator and slow
416
+ * enough that a misconfigured camStream costs ~12 op/min, not 5/s.
417
+ */
418
+ var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
419
+ /**
420
+ * Attempts after which a still-failing subscribe escalates from the fast 5 s
421
+ * ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
422
+ * minute of fast retries — plenty for the boot races the 5 s ceiling exists
423
+ * for. A broker that is STILL absent after that is a long-lived condition
424
+ * (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
425
+ * to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
426
+ * churn. The slow loop stays alive so audio still recovers automatically
427
+ * (≤60 s) once the camera is re-enabled.
428
+ */
429
+ var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
430
+ var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
431
+ /**
432
+ * Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
433
+ *
434
+ * Always resolves to a teardown closure — when the broker is not yet
435
+ * registered the closure cancels the ongoing retry loop; when polling is
436
+ * active it stops the loop and releases the broker subscription. Mirrors
437
+ * `startFrameHandlePoller` so video and audio recover identically.
438
+ */
439
+ function startAudioChunkPoller(options) {
440
+ const lifecycle = {
441
+ stopped: false,
442
+ retryTimer: void 0,
443
+ pollTimer: void 0,
444
+ activeSubscriptionId: null
445
+ };
446
+ const teardown = () => {
447
+ if (lifecycle.stopped) return;
448
+ lifecycle.stopped = true;
449
+ if (lifecycle.retryTimer) {
450
+ clearTimeout(lifecycle.retryTimer);
451
+ lifecycle.retryTimer = void 0;
452
+ }
453
+ if (lifecycle.pollTimer) {
454
+ clearTimeout(lifecycle.pollTimer);
455
+ lifecycle.pollTimer = void 0;
456
+ }
457
+ const subId = lifecycle.activeSubscriptionId;
458
+ if (subId) {
459
+ lifecycle.activeSubscriptionId = null;
460
+ options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, require_dist.nodePin(options.ownerNodeId)).catch((err) => {
461
+ options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
462
+ brokerId: options.brokerId,
463
+ subscriptionId: subId,
464
+ error: require_dist.errMsg(err)
465
+ } });
466
+ });
467
+ }
468
+ };
469
+ subscribeWithRetry(options, lifecycle);
470
+ return teardown;
471
+ }
472
+ /**
473
+ * Run the subscribe → poll handshake with exponential backoff on subscribe
474
+ * failures. Resolves once the subscription is acquired (and the poll loop has
475
+ * been started) or once `lifecycle.stopped` flips, whichever comes first.
476
+ */
477
+ async function subscribeWithRetry(options, lifecycle) {
478
+ const { api, brokerId, tag, ownerNodeId, logger } = options;
479
+ const pin = require_dist.nodePin(ownerNodeId);
480
+ let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
481
+ let attempt = 0;
482
+ while (!lifecycle.stopped) {
483
+ attempt += 1;
484
+ try {
485
+ const result = await api.streamBroker.subscribeAudioChunks.mutate({
486
+ brokerId,
487
+ tag,
488
+ ...options.accept ? { accept: options.accept } : {}
489
+ }, pin);
490
+ if (lifecycle.stopped) {
491
+ await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }, pin).catch((err) => {
492
+ logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
493
+ brokerId,
494
+ subscriptionId: result.subscriptionId,
495
+ error: require_dist.errMsg(err)
496
+ } });
497
+ });
498
+ return;
499
+ }
500
+ if (attempt > 1) logger.info("audio-chunk poller: subscribeAudioChunks recovered", { meta: {
501
+ brokerId,
502
+ tag,
503
+ attempt
504
+ } });
505
+ lifecycle.activeSubscriptionId = result.subscriptionId;
506
+ startPolling(options, lifecycle);
507
+ return;
508
+ } catch (err) {
509
+ if (lifecycle.stopped) return;
510
+ if (attempt === 1) logger.warn("audio-chunk poller: subscribeAudioChunks failed, retrying", { meta: {
511
+ brokerId,
512
+ tag,
513
+ error: require_dist.errMsg(err),
514
+ nextRetryInMs: backoffMs
515
+ } });
516
+ else logger.debug("audio-chunk poller: subscribeAudioChunks still failing", { meta: {
517
+ brokerId,
518
+ tag,
519
+ attempt,
520
+ error: require_dist.errMsg(err),
521
+ nextRetryInMs: backoffMs
522
+ } });
523
+ if (attempt === PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS) logger.warn("audio-chunk poller: broker still absent after a minute — slowing the retry", { meta: {
524
+ brokerId,
525
+ tag,
526
+ attempt,
527
+ error: require_dist.errMsg(err),
528
+ nextRetryInMs: LONG_SUBSCRIBE_RETRY_BACKOFF_MS,
529
+ hint: "a DISABLED camera, or a camStreamId the broker never registered — audio stays absent until the broker appears"
530
+ } });
531
+ await sleep(backoffMs, lifecycle);
532
+ backoffMs = Math.min(attempt >= PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS ? LONG_SUBSCRIBE_RETRY_BACKOFF_MS : MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
533
+ }
534
+ }
535
+ }
536
+ /**
537
+ * Drive the steady-state poll loop. Mid-stream `pullAudioChunks` failures
538
+ * trigger a fresh `subscribeAudioChunks` via the recovery branch — covers
539
+ * the broker child restart case where our `subscriptionId` is silently
540
+ * disowned.
541
+ */
542
+ function startPolling(options, lifecycle) {
543
+ const { api, brokerId, tag, ownerNodeId, onChunk, logger } = options;
544
+ const pin = require_dist.nodePin(ownerNodeId);
545
+ let consecutiveFailures = 0;
546
+ const resubscribe = async () => {
547
+ try {
548
+ const result = await api.streamBroker.subscribeAudioChunks.mutate({
549
+ brokerId,
550
+ tag,
551
+ ...options.accept ? { accept: options.accept } : {}
552
+ }, pin);
553
+ lifecycle.activeSubscriptionId = result.subscriptionId;
554
+ logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
555
+ brokerId,
556
+ tag,
557
+ subscriptionId: result.subscriptionId,
558
+ afterFailures: consecutiveFailures
559
+ } });
560
+ return true;
561
+ } catch {
562
+ return false;
563
+ }
564
+ };
565
+ const tick = async () => {
566
+ if (lifecycle.stopped) return;
567
+ const subId = lifecycle.activeSubscriptionId;
568
+ if (!subId) return;
569
+ try {
570
+ const chunks = await api.streamBroker.pullAudioChunks.query({
571
+ subscriptionId: subId,
572
+ maxCount: PULL_MAX_COUNT
573
+ }, pin);
574
+ if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
575
+ brokerId,
576
+ subscriptionId: subId
577
+ } });
578
+ consecutiveFailures = 0;
579
+ for (const chunk of chunks) {
580
+ if (lifecycle.stopped) break;
581
+ await onChunk(chunk);
582
+ }
583
+ } catch (err) {
584
+ consecutiveFailures += 1;
585
+ if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
586
+ brokerId,
587
+ subscriptionId: subId,
588
+ error: require_dist.errMsg(err)
589
+ } });
590
+ if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
591
+ }
592
+ if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), options.pollIntervalMs ?? 200);
593
+ };
594
+ tick();
595
+ }
596
+ /**
597
+ * Cancellable sleep — wakes early when `lifecycle.stopped` flips. We
598
+ * keep a local wrapper around the shared {@link sleep} helper because
599
+ * the lifecycle tracks the active retry timer for `teardown()` to
600
+ * clear; pure `sleep()` would leak the timer if teardown fired while
601
+ * we were waiting.
602
+ */
603
+ function sleep(ms, lifecycle) {
604
+ return new Promise((resolve) => {
605
+ if (lifecycle.stopped) {
606
+ resolve();
607
+ return;
608
+ }
609
+ lifecycle.retryTimer = setTimeout(() => {
610
+ lifecycle.retryTimer = void 0;
611
+ resolve();
612
+ }, ms);
613
+ });
614
+ }
615
+ function sameFormat(a, b) {
616
+ return a.sampleRate === b.sampleRate && a.channels === b.channels && a.format === b.format;
617
+ }
618
+ var AudioWindowAccumulator = class {
619
+ deviceId;
620
+ onFormat;
621
+ pcmParts = [];
622
+ accumulatedBytes = 0;
623
+ accumulatedMs = 0;
624
+ windowSampleRate = 0;
625
+ windowChannels = 0;
626
+ windowFormat = "f32le";
627
+ windowTimestamp = 0;
628
+ windowOpen = false;
629
+ /** Last format seen on this subscription — survives a flush, unlike the window anchor. */
630
+ lastFormat = null;
631
+ constructor(deviceId, onFormat) {
632
+ this.deviceId = deviceId;
633
+ this.onFormat = onFormat;
634
+ }
635
+ /**
636
+ * Append one decoded PCM chunk to the open window. Returns the flushed
637
+ * `AudioChunkInput` once the accumulated duration reaches
638
+ * `AUDIO_WINDOW_TARGET_MS` (and resets for the next window), else `null`
639
+ * (accumulate-only, no flush yet).
640
+ */
641
+ push(chunk) {
642
+ const byteLength = chunk.data.byteLength;
643
+ const bytes = new Uint8Array(byteLength);
644
+ bytes.set(new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, byteLength));
645
+ const format = chunk.format ?? "f32le";
646
+ this.observeFormat({
647
+ sampleRate: chunk.sampleRate,
648
+ channels: chunk.channels,
649
+ format
650
+ });
651
+ if (!this.windowOpen) {
652
+ this.windowSampleRate = chunk.sampleRate;
653
+ this.windowChannels = chunk.channels;
654
+ this.windowFormat = format;
655
+ this.windowTimestamp = chunk.timestamp;
656
+ this.windowOpen = true;
657
+ }
658
+ this.pcmParts.push(bytes);
659
+ this.accumulatedBytes += byteLength;
660
+ const channels = chunk.channels > 0 ? chunk.channels : 1;
661
+ const framesPerChannel = byteLength / require_dist.audioChunkBytesPerSample(format) / channels;
662
+ this.accumulatedMs += framesPerChannel / chunk.sampleRate * 1e3;
663
+ if (this.accumulatedMs < 1e3) return null;
664
+ const windowData = new Uint8Array(this.accumulatedBytes);
665
+ let offset = 0;
666
+ for (const part of this.pcmParts) {
667
+ windowData.set(part, offset);
668
+ offset += part.byteLength;
669
+ }
670
+ const flushSampleRate = this.windowSampleRate;
671
+ const flushChannels = this.windowChannels;
672
+ const flushFormat = this.windowFormat;
673
+ const flushTimestamp = this.windowTimestamp;
674
+ this.reset();
675
+ return {
676
+ data: flushFormat === "f32le" ? windowData : require_dist.expandAudioChunkToF32le(windowData, flushFormat),
677
+ sampleRate: flushSampleRate,
678
+ channels: flushChannels,
679
+ timestamp: flushTimestamp,
680
+ deviceId: this.deviceId
681
+ };
682
+ }
683
+ /**
684
+ * Report the first format and every change; a change under an OPEN window
685
+ * drops the partial window so the flushed bytes never mix two rates.
686
+ */
687
+ observeFormat(format) {
688
+ const previous = this.lastFormat;
689
+ if (previous !== null && sameFormat(previous, format)) return;
690
+ const droppedMs = this.windowOpen ? Math.round(this.accumulatedMs) : 0;
691
+ if (this.windowOpen) this.reset();
692
+ this.lastFormat = format;
693
+ this.onFormat?.({
694
+ from: previous,
695
+ to: format,
696
+ droppedMs
697
+ });
698
+ }
699
+ /**
700
+ * Drop any partially-accumulated window. Called on unsubscribe / device
701
+ * audio stop (no trailing flush — a sub-second partial window is not worth
702
+ * a classify during teardown) and internally after every flush.
703
+ */
704
+ reset() {
705
+ this.pcmParts = [];
706
+ this.accumulatedBytes = 0;
707
+ this.accumulatedMs = 0;
708
+ this.windowOpen = false;
709
+ }
710
+ };
711
+ /**
712
+ * Build the canonical `AudioResult` frame from one `analyseChunk` result —
713
+ * extracted verbatim from `subscribeAudioStream`'s result→event-payload
714
+ * mapping. The caller wraps this in the `pipeline.audio-inference-result`
715
+ * event; building the frame is pure and needs no controller state.
716
+ */
717
+ function buildAudioResultFrame(deviceId, result) {
718
+ const windowId = `${deviceId}-${result.timestamp}`;
719
+ const audioDetections = [];
720
+ let idCounter = 0;
721
+ if (result.classification && result.classification.labels.length > 0) for (const label of result.classification.labels) {
722
+ idCounter += 1;
723
+ audioDetections.push({
724
+ id: `a${idCounter}`,
725
+ kind: "audio",
726
+ macroClass: label.className,
727
+ score: label.score,
728
+ labels: [{
729
+ label: label.className,
730
+ score: label.score
731
+ }],
732
+ startMs: 0,
733
+ endMs: 0
734
+ });
735
+ }
736
+ return {
737
+ kind: "audio-window",
738
+ windowId,
739
+ deviceId,
740
+ timestamp: result.timestamp,
741
+ startMs: 0,
742
+ endMs: 0,
743
+ level: {
744
+ rms: result.level.rms,
745
+ dbfs: result.level.dbfs
746
+ },
747
+ detections: audioDetections,
748
+ debug: result.classification ? {
749
+ totalInferenceMs: result.classification.inferenceMs,
750
+ stepTimings: [{
751
+ source: "audio-analyzer",
752
+ ms: result.classification.inferenceMs,
753
+ detectionCount: audioDetections.length
754
+ }]
755
+ } : void 0
756
+ };
757
+ }
758
+ //#endregion
759
+ //#region src/audio-analyzer/audio-device-attachments.ts
760
+ /**
761
+ * `AudioDeviceAttachments` — the analyzer's own audio subscriptions (D461).
762
+ *
763
+ * ## What this replaces
764
+ *
765
+ * Until 2026-09-11 the decoded audio-chunk plane ran through
766
+ * `pipeline-orchestrator`: it drained the broker's FIFO, accumulated ~1 s
767
+ * windows and pushed them back out to `audioAnalyzer.analyseChunk`. The
768
+ * orchestrator neither produced nor consumed the audio — it buffered it, and
769
+ * every byte crossed hub-main twice for the privilege. The poller's own
770
+ * docblock named the reason, and it was an accident: the code had been a
771
+ * closure INSIDE the broker's process, and when the `pipeline` co-location
772
+ * group was dissolved nobody moved it.
773
+ *
774
+ * D455 proved the relay was the PREMISE of the remaining cost rather than an
775
+ * alternative to it. It put the SOURCE bytes on the plane — one G.711 byte per
776
+ * sample instead of four f32le ones — but only for a consumer that declared it
777
+ * could read them, and the accumulator (the declaring consumer) sat in a third
778
+ * process that had to expand before forwarding. So legs one and two went coded
779
+ * and legs three and four stayed at ~49 MB/min each. Here the subscriber IS
780
+ * the decoder: there is no third interlocutor, the negotiation needs none, and
781
+ * legs three and four do not exist.
782
+ *
783
+ * ## What the orchestrator still owns
784
+ *
785
+ * Everything about WHETHER and WHERE, which is all policy: the `audioMode`
786
+ * gate, the on-motion window, the audio-track probe, the per-device node
787
+ * assignment and the settings read. It calls `attachDevice` / `detachDevice`
788
+ * and holds the teardown. What it no longer owns is the bytes.
789
+ *
790
+ * ## The failure this split ADDS, and where it is caught
791
+ *
792
+ * The broker subscription is now opened by a process the orchestrator does not
793
+ * supervise. A detach that never arrives — an analyzer runner that crashed, a
794
+ * node that went offline mid-teardown — leaves a FIFO nobody drains, and on
795
+ * this plane that is not merely a leak: `AudioChunkPlane.subscriberCount` is
796
+ * broker DEMAND, so an orphan pins the source dial and the decode session open
797
+ * for as long as the broker lives. It is caught in the broker
798
+ * (`AudioChunkPlane`'s idle lease), not here, because a process that has died
799
+ * cannot clean up after itself.
800
+ */
801
+ var AudioDeviceAttachments = class {
802
+ deps;
803
+ attachments = /* @__PURE__ */ new Map();
804
+ shuttingDown = false;
805
+ constructor(deps) {
806
+ this.deps = deps;
807
+ }
808
+ /** Live attachment count — diagnostics only. */
809
+ get size() {
810
+ return this.attachments.size;
811
+ }
812
+ /**
813
+ * Open (or replace) this camera's audio pull. Returns as soon as the
814
+ * attachment exists: the first `subscribeAudioChunks` retries with backoff
815
+ * behind it, because the broker for a camStream may not be registered yet.
816
+ * That boot-race tolerance travelled with the poller and is unchanged.
817
+ *
818
+ * Idempotent BY REPLACEMENT. Two pollers on one camera would double the
819
+ * broker's fanout and each would flush its own half-windows; a re-attach
820
+ * (a rebalance, a settings change, a watchdog recovery) must therefore drop
821
+ * the prior one, and it is reported so an unexpected churn is visible.
822
+ */
823
+ attach(request) {
824
+ if (this.shuttingDown) {
825
+ this.deps.logger.warn("audio attach REFUSED — analyzer is shutting down", {
826
+ tags: { deviceId: request.deviceId },
827
+ meta: { brokerId: request.brokerId }
828
+ });
829
+ return {
830
+ attached: false,
831
+ replaced: false
832
+ };
833
+ }
834
+ const api = this.deps.api();
835
+ if (!api) {
836
+ this.deps.logger.warn("audio attach REFUSED — no cross-addon api available", {
837
+ tags: { deviceId: request.deviceId },
838
+ meta: { brokerId: request.brokerId }
839
+ });
840
+ return {
841
+ attached: false,
842
+ replaced: false
843
+ };
844
+ }
845
+ const replaced = this.releaseAttachment(request.deviceId, "replaced");
846
+ const { deviceId, brokerId, ingestNodeId, settings } = request;
847
+ const logger = this.deps.logger.withTags({ deviceId });
848
+ const isRemoteBroker = ingestNodeId !== this.deps.localNodeId();
849
+ const accumulator = new AudioWindowAccumulator(deviceId, (change) => {
850
+ const meta = {
851
+ sampleRate: change.to.sampleRate,
852
+ channels: change.to.channels,
853
+ format: change.to.format,
854
+ previous: change.from,
855
+ droppedMs: change.droppedMs
856
+ };
857
+ if (change.from === null) {
858
+ logger.info("audio stream delivering", {
859
+ tags: { deviceId },
860
+ meta
861
+ });
862
+ return;
863
+ }
864
+ logger.warn("audio stream format changed — partial window dropped", {
865
+ tags: { deviceId },
866
+ meta
867
+ });
868
+ });
869
+ const teardown = startAudioChunkPoller({
870
+ api,
871
+ brokerId,
872
+ tag: "audio-analyzer",
873
+ ownerNodeId: ingestNodeId,
874
+ accept: ["pcmu", "pcma"],
875
+ pollIntervalMs: isRemoteBroker ? 500 : 200,
876
+ logger,
877
+ onChunk: async (chunk) => {
878
+ try {
879
+ const window = accumulator.push(chunk);
880
+ if (!window) return;
881
+ const result = await this.deps.analyse(window, settings);
882
+ if (!result) return;
883
+ this.deps.emitResult(deviceId, buildAudioResultFrame(deviceId, result));
884
+ } catch (err) {
885
+ logger.error("Audio analysis failed", {
886
+ tags: { deviceId },
887
+ meta: { error: require_dist.errMsg(err) }
888
+ });
889
+ }
890
+ }
891
+ });
892
+ this.attachments.set(deviceId, {
893
+ brokerId,
894
+ teardown,
895
+ accumulator
896
+ });
897
+ this.deps.logger.info("audio attached", {
898
+ tags: { deviceId },
899
+ meta: {
900
+ brokerId,
901
+ ingestNodeId,
902
+ remoteBroker: isRemoteBroker,
903
+ pollIntervalMs: isRemoteBroker ? 500 : 200,
904
+ replaced
905
+ }
906
+ });
907
+ return {
908
+ attached: true,
909
+ replaced
910
+ };
911
+ }
912
+ /** Close this camera's audio pull and release the broker subscription. Idempotent. */
913
+ detach(deviceId) {
914
+ return { detached: this.releaseAttachment(deviceId, "detached") };
915
+ }
916
+ /**
917
+ * Release every attachment — the analyzer's own shutdown. This is the only
918
+ * orderly path that can return the broker's FIFOs; a crash cannot, which is
919
+ * what the plane's idle lease exists for.
920
+ */
921
+ detachAll() {
922
+ this.shuttingDown = true;
923
+ for (const deviceId of [...this.attachments.keys()]) this.releaseAttachment(deviceId, "shutdown");
924
+ }
925
+ releaseAttachment(deviceId, reason) {
926
+ const existing = this.attachments.get(deviceId);
927
+ if (!existing) return false;
928
+ this.attachments.delete(deviceId);
929
+ try {
930
+ existing.teardown();
931
+ } catch (err) {
932
+ this.deps.logger.warn("audio detach: poller teardown threw", {
933
+ tags: { deviceId },
934
+ meta: {
935
+ brokerId: existing.brokerId,
936
+ reason,
937
+ error: require_dist.errMsg(err)
938
+ }
939
+ });
940
+ }
941
+ existing.accumulator.reset();
942
+ this.deps.logger.info("audio detached", {
943
+ tags: { deviceId },
944
+ meta: {
945
+ brokerId: existing.brokerId,
946
+ reason
947
+ }
948
+ });
949
+ return true;
950
+ }
951
+ };
400
952
  //#endregion
401
953
  //#region src/audio-analyzer/addons/analyzer/index.ts
402
954
  /**
@@ -433,6 +985,7 @@ var AudioAnalyzerProvider = class {
433
985
  deviceContributionResolver;
434
986
  deviceSettingsPatcher;
435
987
  reprobeImpl;
988
+ attachments;
436
989
  log;
437
990
  classifyCallCount = 0;
438
991
  lastClassifyErrorMs = 0;
@@ -466,12 +1019,13 @@ var AudioAnalyzerProvider = class {
466
1019
  /** Set on `dispose()` (addon stop) so a late classify/idle-timer never
467
1020
  * re-spawns the subprocess after teardown. */
468
1021
  disposed = false;
469
- constructor(logger, pipelineFactory, initialBackend, deviceSettingsResolver, deviceContributionResolver, deviceSettingsPatcher, reprobeImpl) {
1022
+ constructor(logger, pipelineFactory, initialBackend, deviceSettingsResolver, deviceContributionResolver, deviceSettingsPatcher, reprobeImpl, attachments) {
470
1023
  this.pipelineFactory = pipelineFactory;
471
1024
  this.deviceSettingsResolver = deviceSettingsResolver;
472
1025
  this.deviceContributionResolver = deviceContributionResolver;
473
1026
  this.deviceSettingsPatcher = deviceSettingsPatcher;
474
1027
  this.reprobeImpl = reprobeImpl;
1028
+ this.attachments = attachments;
475
1029
  this.log = logger;
476
1030
  this.backendName = initialBackend;
477
1031
  this.desiredBackend = initialBackend;
@@ -609,6 +1163,20 @@ var AudioAnalyzerProvider = class {
609
1163
  async resolveDeviceSettings({ deviceId }) {
610
1164
  return this.deviceSettingsResolver(deviceId);
611
1165
  }
1166
+ /**
1167
+ * Start pulling this camera's audio from the broker, in THIS process (D461).
1168
+ * Thin delegate — the attachment registry owns the poller, the window and
1169
+ * the result event; this method exists so the orchestrator can express the
1170
+ * policy it still owns ("this camera's audio runs, on this node") without
1171
+ * owning the bytes.
1172
+ */
1173
+ async attachDevice(input) {
1174
+ return this.attachments.attach(input);
1175
+ }
1176
+ /** Stop pulling this camera's audio and release the broker subscription. */
1177
+ async detachDevice({ deviceId }) {
1178
+ return this.attachments.detach(deviceId);
1179
+ }
612
1180
  async analyseChunk({ chunk, settings }) {
613
1181
  const samples = float32FromBytes(chunk.data);
614
1182
  this.reportWindowFormat(chunk, samples.length);
@@ -846,6 +1414,13 @@ var AudioAnalyzerAddon = class extends require_dist.BaseAddon {
846
1414
  * in-place rebuild in `onConfigChanged` (rebuild only when it genuinely
847
1415
  * flips). The provider owns the (lazy) pipeline itself. */
848
1416
  activeBackend = null;
1417
+ /**
1418
+ * This node's live audio attachments (D461). Held on the ADDON rather than
1419
+ * only inside the provider because shutdown must release the broker's FIFOs
1420
+ * even if the provider never came up — an orphaned audio subscription is
1421
+ * broker DEMAND, not merely a leak.
1422
+ */
1423
+ attachments = null;
849
1424
  constructor() {
850
1425
  super(require_dist.DEFAULT_AUDIO_ANALYZER_CONFIG);
851
1426
  }
@@ -990,7 +1565,40 @@ var AudioAnalyzerAddon = class extends require_dist.BaseAddon {
990
1565
  return null;
991
1566
  };
992
1567
  const deviceSettingsPatcher = async (_deviceId, _patch) => {};
993
- this.provider = new AudioAnalyzerProvider(logger, (b) => this.buildPipeline(b), backend, deviceSettingsResolver, deviceContributionResolver, deviceSettingsPatcher, () => this.reprobeAudioEngine());
1568
+ this.attachments = new AudioDeviceAttachments({
1569
+ api: () => this.ctx.api,
1570
+ localNodeId: () => this.ctx.kernel?.localNodeId ?? "hub",
1571
+ logger: logger.child("audio-attach"),
1572
+ analyse: async (chunk, settings) => {
1573
+ const provider = this.provider;
1574
+ if (!provider) return null;
1575
+ return provider.analyseChunk({
1576
+ chunk,
1577
+ settings
1578
+ });
1579
+ },
1580
+ emitResult: (deviceId, frame) => {
1581
+ const nodeId = this.ctx.kernel?.localNodeId ?? "hub";
1582
+ this.ctx.eventBus.emit({
1583
+ id: `audio-inference-${deviceId}-${Date.now()}`,
1584
+ timestamp: /* @__PURE__ */ new Date(),
1585
+ source: {
1586
+ type: "device",
1587
+ id: deviceId,
1588
+ nodeId,
1589
+ addonId: "audio-analyzer",
1590
+ deviceId
1591
+ },
1592
+ category: require_dist.EventCategory.PipelineAudioInferenceResult,
1593
+ data: {
1594
+ deviceId,
1595
+ frame,
1596
+ nodeId
1597
+ }
1598
+ });
1599
+ }
1600
+ });
1601
+ this.provider = new AudioAnalyzerProvider(logger, (b) => this.buildPipeline(b), backend, deviceSettingsResolver, deviceContributionResolver, deviceSettingsPatcher, () => this.reprobeAudioEngine(), this.attachments);
994
1602
  return [{
995
1603
  capability: require_dist.audioAnalyzerCapability,
996
1604
  provider: this.provider
@@ -1000,6 +1608,8 @@ var AudioAnalyzerAddon = class extends require_dist.BaseAddon {
1000
1608
  }];
1001
1609
  }
1002
1610
  async onShutdown() {
1611
+ this.attachments?.detachAll();
1612
+ this.attachments = null;
1003
1613
  if (this.provider) {
1004
1614
  await this.provider.dispose();
1005
1615
  this.provider = null;