@camstack/addon-pipeline 1.2.219 → 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 (40) hide show
  1. package/dist/audio-analyzer/index.js +661 -7
  2. package/dist/audio-analyzer/index.mjs +658 -5
  3. package/dist/chunk-emK7D4bc.js +35 -0
  4. package/dist/{default-detection-model-d7-Q_LMo.js → default-detection-model-B1NT1A38.js} +1 -1
  5. package/dist/{default-detection-model-Dsk1a5pY.mjs → default-detection-model-DkVFgubU.mjs} +1 -1
  6. package/dist/detection-pipeline/index.js +10 -9
  7. package/dist/detection-pipeline/index.mjs +4 -4
  8. package/dist/{dist-wtNhtVVo.js → dist-ATDfcxgL.js} +282 -39
  9. package/dist/{dist-CKK0CBUW.mjs → dist-CEFC3PkP.mjs} +265 -5
  10. package/dist/{lazy-sharp-zoQ07I43.js → lazy-sharp-BuwKlQLR.js} +2 -2
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-BduNi21Z.js → node-C5jnTgpv.js} +4 -3
  14. package/dist/{node-CrNBWFbA.mjs → node-DR3fHqUq.mjs} +1 -1
  15. package/dist/pipeline-runner/index.js +222 -157
  16. package/dist/pipeline-runner/index.mjs +218 -154
  17. package/dist/{process-memory-Dwl0MFPH.js → process-memory-DQbCPnMA.js} +3 -2
  18. package/dist/{process-memory-BRQOtDN1.mjs → process-memory-DqYj5IAb.mjs} +1 -1
  19. package/dist/recorder/index.js +344 -127
  20. package/dist/recorder/index.mjs +342 -126
  21. package/dist/{segment-demux-js-D2o9ShNL.mjs → segment-demux-js-CddDwD69.mjs} +1 -1
  22. package/dist/{segment-demux-js-CFOU3biG.js → segment-demux-js-o9KBAz9p.js} +4 -3
  23. package/dist/session-decode/decode-worker-child.js +155 -74
  24. package/dist/session-decode/decode-worker-child.mjs +154 -73
  25. package/dist/stream-broker/_stub.js +2 -2
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DN_IoShh.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D5XnLZWR.mjs} +3 -3
  27. 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
  28. 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
  29. package/dist/stream-broker/demux-worker-child.js +1 -1
  30. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  31. package/dist/stream-broker/{hostInit-n7c9pNlt.mjs → hostInit-C-VdcJS2.mjs} +3 -3
  32. package/dist/stream-broker/index.js +231 -112
  33. package/dist/stream-broker/index.mjs +224 -106
  34. package/dist/stream-broker/remoteEntry.js +1 -1
  35. package/dist/worker-protocol-B2MfQLlu.js +184 -0
  36. package/dist/worker-protocol-C_W-P_g-.mjs +161 -0
  37. package/package.json +1 -1
  38. 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
  39. package/dist/worker-protocol-Cx3Nqreq.mjs +0 -264
  40. package/dist/worker-protocol-iWTFnnUM.js +0 -299
@@ -1,6 +1,6 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { Dt as resolvePoolMemoryPolicy, It as errMsg, Jt as hydrateSchema, M as audioAnalyzerCapability, S as PoolMemoryWatchdog, Ut as BaseAddon, j as audioAnalysisCapability, n as AUDIO_BACKEND_CHOICES, ot as mapAudioLabelToMacro, s as DEFAULT_AUDIO_ANALYZER_CONFIG, v as HF_BASE_URL } from "../dist-CKK0CBUW.mjs";
3
- import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-BRQOtDN1.mjs";
2
+ import { C as PoolMemoryWatchdog, Jt as audioChunkBytesPerSample, Lt as errMsg, M as audioAnalysisCapability, N as audioAnalyzerCapability, Ot as resolvePoolMemoryPolicy, Wt as BaseAddon, Xt as expandAudioChunkToF32le, Zt as hydrateSchema, n as AUDIO_BACKEND_CHOICES, s as DEFAULT_AUDIO_ANALYZER_CONFIG, st as mapAudioLabelToMacro, tn as nodePin, vn as EventCategory, y as HF_BASE_URL } from "../dist-CEFC3PkP.mjs";
3
+ import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-DqYj5IAb.mjs";
4
4
  import * as fs from "node:fs";
5
5
  import * as path$1 from "node:path";
6
6
  import { downloadFile } from "@camstack/system/addon-utils";
@@ -364,7 +364,12 @@ var AppleSoundAnalysisPipeline = class {
364
364
  }
365
365
  }
366
366
  };
367
- /** Simple resample to 16kHz mono by linear interpolation. */
367
+ /**
368
+ * Resample to 16 kHz mono by linear interpolation — the ONE place a window
369
+ * is brought to the classifier's rate. The plane carries the source rate
370
+ * (8 kHz for G.711, D450); the model that needs 16 kHz gets it here, next to
371
+ * the model, over a whole window. Exported so the guard can pin it.
372
+ */
368
373
  function resampleMono16k(chunk) {
369
374
  const { data, sampleRate, channels } = chunk;
370
375
  const numSamples = data.length / channels;
@@ -386,6 +391,558 @@ function resampleMono16k(chunk) {
386
391
  }
387
392
  return out;
388
393
  }
394
+ /** How many chunks to drain per poll — a small burst absorbs jitter. */
395
+ var PULL_MAX_COUNT = 8;
396
+ /**
397
+ * Consecutive pull failures before we attempt to re-subscribe. A single failed
398
+ * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
399
+ * sustained failure means the broker child restarted and dropped our
400
+ * subscription, so we re-establish it.
401
+ */
402
+ var RESUBSCRIBE_AFTER_FAILURES = 2;
403
+ /** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
404
+ var RESUBSCRIBE_THROTTLE_TICKS = 5;
405
+ /** First subscribe-retry delay, doubled on every subsequent failure. */
406
+ var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
407
+ /**
408
+ * Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller — fast
409
+ * enough to recover within a single reconcile of the orchestrator and slow
410
+ * enough that a misconfigured camStream costs ~12 op/min, not 5/s.
411
+ */
412
+ var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
413
+ /**
414
+ * Attempts after which a still-failing subscribe escalates from the fast 5 s
415
+ * ceiling to {@link LONG_SUBSCRIBE_RETRY_BACKOFF_MS}. ~15 attempts ≈ one
416
+ * minute of fast retries — plenty for the boot races the 5 s ceiling exists
417
+ * for. A broker that is STILL absent after that is a long-lived condition
418
+ * (e.g. a DISABLED camera, whose broker the stream-broker reconcile refuses
419
+ * to recreate — 2026-07-23) and retrying every 5 s forever is pure log/RPC
420
+ * churn. The slow loop stays alive so audio still recovers automatically
421
+ * (≤60 s) once the camera is re-enabled.
422
+ */
423
+ var PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS = 15;
424
+ var LONG_SUBSCRIBE_RETRY_BACKOFF_MS = 6e4;
425
+ /**
426
+ * Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
427
+ *
428
+ * Always resolves to a teardown closure — when the broker is not yet
429
+ * registered the closure cancels the ongoing retry loop; when polling is
430
+ * active it stops the loop and releases the broker subscription. Mirrors
431
+ * `startFrameHandlePoller` so video and audio recover identically.
432
+ */
433
+ function startAudioChunkPoller(options) {
434
+ const lifecycle = {
435
+ stopped: false,
436
+ retryTimer: void 0,
437
+ pollTimer: void 0,
438
+ activeSubscriptionId: null
439
+ };
440
+ const teardown = () => {
441
+ if (lifecycle.stopped) return;
442
+ lifecycle.stopped = true;
443
+ if (lifecycle.retryTimer) {
444
+ clearTimeout(lifecycle.retryTimer);
445
+ lifecycle.retryTimer = void 0;
446
+ }
447
+ if (lifecycle.pollTimer) {
448
+ clearTimeout(lifecycle.pollTimer);
449
+ lifecycle.pollTimer = void 0;
450
+ }
451
+ const subId = lifecycle.activeSubscriptionId;
452
+ if (subId) {
453
+ lifecycle.activeSubscriptionId = null;
454
+ options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
455
+ options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
456
+ brokerId: options.brokerId,
457
+ subscriptionId: subId,
458
+ error: errMsg(err)
459
+ } });
460
+ });
461
+ }
462
+ };
463
+ subscribeWithRetry(options, lifecycle);
464
+ return teardown;
465
+ }
466
+ /**
467
+ * Run the subscribe → poll handshake with exponential backoff on subscribe
468
+ * failures. Resolves once the subscription is acquired (and the poll loop has
469
+ * been started) or once `lifecycle.stopped` flips, whichever comes first.
470
+ */
471
+ async function subscribeWithRetry(options, lifecycle) {
472
+ const { api, brokerId, tag, ownerNodeId, logger } = options;
473
+ const pin = nodePin(ownerNodeId);
474
+ let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
475
+ let attempt = 0;
476
+ while (!lifecycle.stopped) {
477
+ attempt += 1;
478
+ try {
479
+ const result = await api.streamBroker.subscribeAudioChunks.mutate({
480
+ brokerId,
481
+ tag,
482
+ ...options.accept ? { accept: options.accept } : {}
483
+ }, pin);
484
+ if (lifecycle.stopped) {
485
+ await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }, pin).catch((err) => {
486
+ logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
487
+ brokerId,
488
+ subscriptionId: result.subscriptionId,
489
+ error: errMsg(err)
490
+ } });
491
+ });
492
+ return;
493
+ }
494
+ if (attempt > 1) logger.info("audio-chunk poller: subscribeAudioChunks recovered", { meta: {
495
+ brokerId,
496
+ tag,
497
+ attempt
498
+ } });
499
+ lifecycle.activeSubscriptionId = result.subscriptionId;
500
+ startPolling(options, lifecycle);
501
+ return;
502
+ } catch (err) {
503
+ if (lifecycle.stopped) return;
504
+ if (attempt === 1) logger.warn("audio-chunk poller: subscribeAudioChunks failed, retrying", { meta: {
505
+ brokerId,
506
+ tag,
507
+ error: errMsg(err),
508
+ nextRetryInMs: backoffMs
509
+ } });
510
+ else logger.debug("audio-chunk poller: subscribeAudioChunks still failing", { meta: {
511
+ brokerId,
512
+ tag,
513
+ attempt,
514
+ error: errMsg(err),
515
+ nextRetryInMs: backoffMs
516
+ } });
517
+ if (attempt === PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS) logger.warn("audio-chunk poller: broker still absent after a minute — slowing the retry", { meta: {
518
+ brokerId,
519
+ tag,
520
+ attempt,
521
+ error: errMsg(err),
522
+ nextRetryInMs: LONG_SUBSCRIBE_RETRY_BACKOFF_MS,
523
+ hint: "a DISABLED camera, or a camStreamId the broker never registered — audio stays absent until the broker appears"
524
+ } });
525
+ await sleep(backoffMs, lifecycle);
526
+ backoffMs = Math.min(attempt >= PERSISTENT_SUBSCRIBE_FAILURE_ATTEMPTS ? LONG_SUBSCRIBE_RETRY_BACKOFF_MS : MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
527
+ }
528
+ }
529
+ }
530
+ /**
531
+ * Drive the steady-state poll loop. Mid-stream `pullAudioChunks` failures
532
+ * trigger a fresh `subscribeAudioChunks` via the recovery branch — covers
533
+ * the broker child restart case where our `subscriptionId` is silently
534
+ * disowned.
535
+ */
536
+ function startPolling(options, lifecycle) {
537
+ const { api, brokerId, tag, ownerNodeId, onChunk, logger } = options;
538
+ const pin = nodePin(ownerNodeId);
539
+ let consecutiveFailures = 0;
540
+ const resubscribe = async () => {
541
+ try {
542
+ const result = await api.streamBroker.subscribeAudioChunks.mutate({
543
+ brokerId,
544
+ tag,
545
+ ...options.accept ? { accept: options.accept } : {}
546
+ }, pin);
547
+ lifecycle.activeSubscriptionId = result.subscriptionId;
548
+ logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
549
+ brokerId,
550
+ tag,
551
+ subscriptionId: result.subscriptionId,
552
+ afterFailures: consecutiveFailures
553
+ } });
554
+ return true;
555
+ } catch {
556
+ return false;
557
+ }
558
+ };
559
+ const tick = async () => {
560
+ if (lifecycle.stopped) return;
561
+ const subId = lifecycle.activeSubscriptionId;
562
+ if (!subId) return;
563
+ try {
564
+ const chunks = await api.streamBroker.pullAudioChunks.query({
565
+ subscriptionId: subId,
566
+ maxCount: PULL_MAX_COUNT
567
+ }, pin);
568
+ if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
569
+ brokerId,
570
+ subscriptionId: subId
571
+ } });
572
+ consecutiveFailures = 0;
573
+ for (const chunk of chunks) {
574
+ if (lifecycle.stopped) break;
575
+ await onChunk(chunk);
576
+ }
577
+ } catch (err) {
578
+ consecutiveFailures += 1;
579
+ if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
580
+ brokerId,
581
+ subscriptionId: subId,
582
+ error: errMsg(err)
583
+ } });
584
+ if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
585
+ }
586
+ if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), options.pollIntervalMs ?? 200);
587
+ };
588
+ tick();
589
+ }
590
+ /**
591
+ * Cancellable sleep — wakes early when `lifecycle.stopped` flips. We
592
+ * keep a local wrapper around the shared {@link sleep} helper because
593
+ * the lifecycle tracks the active retry timer for `teardown()` to
594
+ * clear; pure `sleep()` would leak the timer if teardown fired while
595
+ * we were waiting.
596
+ */
597
+ function sleep(ms, lifecycle) {
598
+ return new Promise((resolve) => {
599
+ if (lifecycle.stopped) {
600
+ resolve();
601
+ return;
602
+ }
603
+ lifecycle.retryTimer = setTimeout(() => {
604
+ lifecycle.retryTimer = void 0;
605
+ resolve();
606
+ }, ms);
607
+ });
608
+ }
609
+ function sameFormat(a, b) {
610
+ return a.sampleRate === b.sampleRate && a.channels === b.channels && a.format === b.format;
611
+ }
612
+ var AudioWindowAccumulator = class {
613
+ deviceId;
614
+ onFormat;
615
+ pcmParts = [];
616
+ accumulatedBytes = 0;
617
+ accumulatedMs = 0;
618
+ windowSampleRate = 0;
619
+ windowChannels = 0;
620
+ windowFormat = "f32le";
621
+ windowTimestamp = 0;
622
+ windowOpen = false;
623
+ /** Last format seen on this subscription — survives a flush, unlike the window anchor. */
624
+ lastFormat = null;
625
+ constructor(deviceId, onFormat) {
626
+ this.deviceId = deviceId;
627
+ this.onFormat = onFormat;
628
+ }
629
+ /**
630
+ * Append one decoded PCM chunk to the open window. Returns the flushed
631
+ * `AudioChunkInput` once the accumulated duration reaches
632
+ * `AUDIO_WINDOW_TARGET_MS` (and resets for the next window), else `null`
633
+ * (accumulate-only, no flush yet).
634
+ */
635
+ push(chunk) {
636
+ const byteLength = chunk.data.byteLength;
637
+ const bytes = new Uint8Array(byteLength);
638
+ bytes.set(new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, byteLength));
639
+ const format = chunk.format ?? "f32le";
640
+ this.observeFormat({
641
+ sampleRate: chunk.sampleRate,
642
+ channels: chunk.channels,
643
+ format
644
+ });
645
+ if (!this.windowOpen) {
646
+ this.windowSampleRate = chunk.sampleRate;
647
+ this.windowChannels = chunk.channels;
648
+ this.windowFormat = format;
649
+ this.windowTimestamp = chunk.timestamp;
650
+ this.windowOpen = true;
651
+ }
652
+ this.pcmParts.push(bytes);
653
+ this.accumulatedBytes += byteLength;
654
+ const channels = chunk.channels > 0 ? chunk.channels : 1;
655
+ const framesPerChannel = byteLength / audioChunkBytesPerSample(format) / channels;
656
+ this.accumulatedMs += framesPerChannel / chunk.sampleRate * 1e3;
657
+ if (this.accumulatedMs < 1e3) return null;
658
+ const windowData = new Uint8Array(this.accumulatedBytes);
659
+ let offset = 0;
660
+ for (const part of this.pcmParts) {
661
+ windowData.set(part, offset);
662
+ offset += part.byteLength;
663
+ }
664
+ const flushSampleRate = this.windowSampleRate;
665
+ const flushChannels = this.windowChannels;
666
+ const flushFormat = this.windowFormat;
667
+ const flushTimestamp = this.windowTimestamp;
668
+ this.reset();
669
+ return {
670
+ data: flushFormat === "f32le" ? windowData : expandAudioChunkToF32le(windowData, flushFormat),
671
+ sampleRate: flushSampleRate,
672
+ channels: flushChannels,
673
+ timestamp: flushTimestamp,
674
+ deviceId: this.deviceId
675
+ };
676
+ }
677
+ /**
678
+ * Report the first format and every change; a change under an OPEN window
679
+ * drops the partial window so the flushed bytes never mix two rates.
680
+ */
681
+ observeFormat(format) {
682
+ const previous = this.lastFormat;
683
+ if (previous !== null && sameFormat(previous, format)) return;
684
+ const droppedMs = this.windowOpen ? Math.round(this.accumulatedMs) : 0;
685
+ if (this.windowOpen) this.reset();
686
+ this.lastFormat = format;
687
+ this.onFormat?.({
688
+ from: previous,
689
+ to: format,
690
+ droppedMs
691
+ });
692
+ }
693
+ /**
694
+ * Drop any partially-accumulated window. Called on unsubscribe / device
695
+ * audio stop (no trailing flush — a sub-second partial window is not worth
696
+ * a classify during teardown) and internally after every flush.
697
+ */
698
+ reset() {
699
+ this.pcmParts = [];
700
+ this.accumulatedBytes = 0;
701
+ this.accumulatedMs = 0;
702
+ this.windowOpen = false;
703
+ }
704
+ };
705
+ /**
706
+ * Build the canonical `AudioResult` frame from one `analyseChunk` result —
707
+ * extracted verbatim from `subscribeAudioStream`'s result→event-payload
708
+ * mapping. The caller wraps this in the `pipeline.audio-inference-result`
709
+ * event; building the frame is pure and needs no controller state.
710
+ */
711
+ function buildAudioResultFrame(deviceId, result) {
712
+ const windowId = `${deviceId}-${result.timestamp}`;
713
+ const audioDetections = [];
714
+ let idCounter = 0;
715
+ if (result.classification && result.classification.labels.length > 0) for (const label of result.classification.labels) {
716
+ idCounter += 1;
717
+ audioDetections.push({
718
+ id: `a${idCounter}`,
719
+ kind: "audio",
720
+ macroClass: label.className,
721
+ score: label.score,
722
+ labels: [{
723
+ label: label.className,
724
+ score: label.score
725
+ }],
726
+ startMs: 0,
727
+ endMs: 0
728
+ });
729
+ }
730
+ return {
731
+ kind: "audio-window",
732
+ windowId,
733
+ deviceId,
734
+ timestamp: result.timestamp,
735
+ startMs: 0,
736
+ endMs: 0,
737
+ level: {
738
+ rms: result.level.rms,
739
+ dbfs: result.level.dbfs
740
+ },
741
+ detections: audioDetections,
742
+ debug: result.classification ? {
743
+ totalInferenceMs: result.classification.inferenceMs,
744
+ stepTimings: [{
745
+ source: "audio-analyzer",
746
+ ms: result.classification.inferenceMs,
747
+ detectionCount: audioDetections.length
748
+ }]
749
+ } : void 0
750
+ };
751
+ }
752
+ //#endregion
753
+ //#region src/audio-analyzer/audio-device-attachments.ts
754
+ /**
755
+ * `AudioDeviceAttachments` — the analyzer's own audio subscriptions (D461).
756
+ *
757
+ * ## What this replaces
758
+ *
759
+ * Until 2026-09-11 the decoded audio-chunk plane ran through
760
+ * `pipeline-orchestrator`: it drained the broker's FIFO, accumulated ~1 s
761
+ * windows and pushed them back out to `audioAnalyzer.analyseChunk`. The
762
+ * orchestrator neither produced nor consumed the audio — it buffered it, and
763
+ * every byte crossed hub-main twice for the privilege. The poller's own
764
+ * docblock named the reason, and it was an accident: the code had been a
765
+ * closure INSIDE the broker's process, and when the `pipeline` co-location
766
+ * group was dissolved nobody moved it.
767
+ *
768
+ * D455 proved the relay was the PREMISE of the remaining cost rather than an
769
+ * alternative to it. It put the SOURCE bytes on the plane — one G.711 byte per
770
+ * sample instead of four f32le ones — but only for a consumer that declared it
771
+ * could read them, and the accumulator (the declaring consumer) sat in a third
772
+ * process that had to expand before forwarding. So legs one and two went coded
773
+ * and legs three and four stayed at ~49 MB/min each. Here the subscriber IS
774
+ * the decoder: there is no third interlocutor, the negotiation needs none, and
775
+ * legs three and four do not exist.
776
+ *
777
+ * ## What the orchestrator still owns
778
+ *
779
+ * Everything about WHETHER and WHERE, which is all policy: the `audioMode`
780
+ * gate, the on-motion window, the audio-track probe, the per-device node
781
+ * assignment and the settings read. It calls `attachDevice` / `detachDevice`
782
+ * and holds the teardown. What it no longer owns is the bytes.
783
+ *
784
+ * ## The failure this split ADDS, and where it is caught
785
+ *
786
+ * The broker subscription is now opened by a process the orchestrator does not
787
+ * supervise. A detach that never arrives — an analyzer runner that crashed, a
788
+ * node that went offline mid-teardown — leaves a FIFO nobody drains, and on
789
+ * this plane that is not merely a leak: `AudioChunkPlane.subscriberCount` is
790
+ * broker DEMAND, so an orphan pins the source dial and the decode session open
791
+ * for as long as the broker lives. It is caught in the broker
792
+ * (`AudioChunkPlane`'s idle lease), not here, because a process that has died
793
+ * cannot clean up after itself.
794
+ */
795
+ var AudioDeviceAttachments = class {
796
+ deps;
797
+ attachments = /* @__PURE__ */ new Map();
798
+ shuttingDown = false;
799
+ constructor(deps) {
800
+ this.deps = deps;
801
+ }
802
+ /** Live attachment count — diagnostics only. */
803
+ get size() {
804
+ return this.attachments.size;
805
+ }
806
+ /**
807
+ * Open (or replace) this camera's audio pull. Returns as soon as the
808
+ * attachment exists: the first `subscribeAudioChunks` retries with backoff
809
+ * behind it, because the broker for a camStream may not be registered yet.
810
+ * That boot-race tolerance travelled with the poller and is unchanged.
811
+ *
812
+ * Idempotent BY REPLACEMENT. Two pollers on one camera would double the
813
+ * broker's fanout and each would flush its own half-windows; a re-attach
814
+ * (a rebalance, a settings change, a watchdog recovery) must therefore drop
815
+ * the prior one, and it is reported so an unexpected churn is visible.
816
+ */
817
+ attach(request) {
818
+ if (this.shuttingDown) {
819
+ this.deps.logger.warn("audio attach REFUSED — analyzer is shutting down", {
820
+ tags: { deviceId: request.deviceId },
821
+ meta: { brokerId: request.brokerId }
822
+ });
823
+ return {
824
+ attached: false,
825
+ replaced: false
826
+ };
827
+ }
828
+ const api = this.deps.api();
829
+ if (!api) {
830
+ this.deps.logger.warn("audio attach REFUSED — no cross-addon api available", {
831
+ tags: { deviceId: request.deviceId },
832
+ meta: { brokerId: request.brokerId }
833
+ });
834
+ return {
835
+ attached: false,
836
+ replaced: false
837
+ };
838
+ }
839
+ const replaced = this.releaseAttachment(request.deviceId, "replaced");
840
+ const { deviceId, brokerId, ingestNodeId, settings } = request;
841
+ const logger = this.deps.logger.withTags({ deviceId });
842
+ const isRemoteBroker = ingestNodeId !== this.deps.localNodeId();
843
+ const accumulator = new AudioWindowAccumulator(deviceId, (change) => {
844
+ const meta = {
845
+ sampleRate: change.to.sampleRate,
846
+ channels: change.to.channels,
847
+ format: change.to.format,
848
+ previous: change.from,
849
+ droppedMs: change.droppedMs
850
+ };
851
+ if (change.from === null) {
852
+ logger.info("audio stream delivering", {
853
+ tags: { deviceId },
854
+ meta
855
+ });
856
+ return;
857
+ }
858
+ logger.warn("audio stream format changed — partial window dropped", {
859
+ tags: { deviceId },
860
+ meta
861
+ });
862
+ });
863
+ const teardown = startAudioChunkPoller({
864
+ api,
865
+ brokerId,
866
+ tag: "audio-analyzer",
867
+ ownerNodeId: ingestNodeId,
868
+ accept: ["pcmu", "pcma"],
869
+ pollIntervalMs: isRemoteBroker ? 500 : 200,
870
+ logger,
871
+ onChunk: async (chunk) => {
872
+ try {
873
+ const window = accumulator.push(chunk);
874
+ if (!window) return;
875
+ const result = await this.deps.analyse(window, settings);
876
+ if (!result) return;
877
+ this.deps.emitResult(deviceId, buildAudioResultFrame(deviceId, result));
878
+ } catch (err) {
879
+ logger.error("Audio analysis failed", {
880
+ tags: { deviceId },
881
+ meta: { error: errMsg(err) }
882
+ });
883
+ }
884
+ }
885
+ });
886
+ this.attachments.set(deviceId, {
887
+ brokerId,
888
+ teardown,
889
+ accumulator
890
+ });
891
+ this.deps.logger.info("audio attached", {
892
+ tags: { deviceId },
893
+ meta: {
894
+ brokerId,
895
+ ingestNodeId,
896
+ remoteBroker: isRemoteBroker,
897
+ pollIntervalMs: isRemoteBroker ? 500 : 200,
898
+ replaced
899
+ }
900
+ });
901
+ return {
902
+ attached: true,
903
+ replaced
904
+ };
905
+ }
906
+ /** Close this camera's audio pull and release the broker subscription. Idempotent. */
907
+ detach(deviceId) {
908
+ return { detached: this.releaseAttachment(deviceId, "detached") };
909
+ }
910
+ /**
911
+ * Release every attachment — the analyzer's own shutdown. This is the only
912
+ * orderly path that can return the broker's FIFOs; a crash cannot, which is
913
+ * what the plane's idle lease exists for.
914
+ */
915
+ detachAll() {
916
+ this.shuttingDown = true;
917
+ for (const deviceId of [...this.attachments.keys()]) this.releaseAttachment(deviceId, "shutdown");
918
+ }
919
+ releaseAttachment(deviceId, reason) {
920
+ const existing = this.attachments.get(deviceId);
921
+ if (!existing) return false;
922
+ this.attachments.delete(deviceId);
923
+ try {
924
+ existing.teardown();
925
+ } catch (err) {
926
+ this.deps.logger.warn("audio detach: poller teardown threw", {
927
+ tags: { deviceId },
928
+ meta: {
929
+ brokerId: existing.brokerId,
930
+ reason,
931
+ error: errMsg(err)
932
+ }
933
+ });
934
+ }
935
+ existing.accumulator.reset();
936
+ this.deps.logger.info("audio detached", {
937
+ tags: { deviceId },
938
+ meta: {
939
+ brokerId: existing.brokerId,
940
+ reason
941
+ }
942
+ });
943
+ return true;
944
+ }
945
+ };
389
946
  //#endregion
390
947
  //#region src/audio-analyzer/addons/analyzer/index.ts
391
948
  /**
@@ -399,6 +956,9 @@ var CLASSIFY_ERROR_SUPPRESS_MS = 3e4;
399
956
  var CLASSIFY_MIN_INTERVAL_MS = 500;
400
957
  var PIPELINE_IDLE_DISPOSE_MS = 5 * 6e4;
401
958
  var GLOBAL_DEVICE_KEY = -1;
959
+ /** What the classifier consumes natively; anything else is resampled next to it. */
960
+ var CLASSIFIER_SAMPLE_RATE = 16e3;
961
+ var CLASSIFIER_CHANNELS = 1;
402
962
  /**
403
963
  * Reconstruct a Float32 view over f32le PCM bytes carried in a Uint8Array.
404
964
  *
@@ -419,6 +979,7 @@ var AudioAnalyzerProvider = class {
419
979
  deviceContributionResolver;
420
980
  deviceSettingsPatcher;
421
981
  reprobeImpl;
982
+ attachments;
422
983
  log;
423
984
  classifyCallCount = 0;
424
985
  lastClassifyErrorMs = 0;
@@ -430,6 +991,8 @@ var AudioAnalyzerProvider = class {
430
991
  debugClassifySamples = false;
431
992
  /** Per-camera in-flight state. Key = deviceId (or GLOBAL_DEVICE_KEY for legacy callers). */
432
993
  cameraState = /* @__PURE__ */ new Map();
994
+ /** Last window format seen per camera — the first and every change is logged (D450). */
995
+ windowFormats = /* @__PURE__ */ new Map();
433
996
  /** Global pipeline lock — Apple SA and ONNX are single-channel: only one classify() can
434
997
  * run at a time. Without this, concurrent calls from different cameras overwrite the
435
998
  * single pendingResolve slot in AppleSoundAnalysisPipeline, causing 30s timeouts. */
@@ -450,12 +1013,13 @@ var AudioAnalyzerProvider = class {
450
1013
  /** Set on `dispose()` (addon stop) so a late classify/idle-timer never
451
1014
  * re-spawns the subprocess after teardown. */
452
1015
  disposed = false;
453
- constructor(logger, pipelineFactory, initialBackend, deviceSettingsResolver, deviceContributionResolver, deviceSettingsPatcher, reprobeImpl) {
1016
+ constructor(logger, pipelineFactory, initialBackend, deviceSettingsResolver, deviceContributionResolver, deviceSettingsPatcher, reprobeImpl, attachments) {
454
1017
  this.pipelineFactory = pipelineFactory;
455
1018
  this.deviceSettingsResolver = deviceSettingsResolver;
456
1019
  this.deviceContributionResolver = deviceContributionResolver;
457
1020
  this.deviceSettingsPatcher = deviceSettingsPatcher;
458
1021
  this.reprobeImpl = reprobeImpl;
1022
+ this.attachments = attachments;
459
1023
  this.log = logger;
460
1024
  this.backendName = initialBackend;
461
1025
  this.desiredBackend = initialBackend;
@@ -593,8 +1157,23 @@ var AudioAnalyzerProvider = class {
593
1157
  async resolveDeviceSettings({ deviceId }) {
594
1158
  return this.deviceSettingsResolver(deviceId);
595
1159
  }
1160
+ /**
1161
+ * Start pulling this camera's audio from the broker, in THIS process (D461).
1162
+ * Thin delegate — the attachment registry owns the poller, the window and
1163
+ * the result event; this method exists so the orchestrator can express the
1164
+ * policy it still owns ("this camera's audio runs, on this node") without
1165
+ * owning the bytes.
1166
+ */
1167
+ async attachDevice(input) {
1168
+ return this.attachments.attach(input);
1169
+ }
1170
+ /** Stop pulling this camera's audio and release the broker subscription. */
1171
+ async detachDevice({ deviceId }) {
1172
+ return this.attachments.detach(deviceId);
1173
+ }
596
1174
  async analyseChunk({ chunk, settings }) {
597
1175
  const samples = float32FromBytes(chunk.data);
1176
+ this.reportWindowFormat(chunk, samples.length);
598
1177
  let sumSquares = 0;
599
1178
  for (let i = 0; i < samples.length; i++) sumSquares += samples[i] * samples[i];
600
1179
  const rms = Math.sqrt(sumSquares / samples.length);
@@ -663,6 +1242,38 @@ var AudioAnalyzerProvider = class {
663
1242
  timestamp: chunk.timestamp
664
1243
  };
665
1244
  }
1245
+ /**
1246
+ * Say what this camera's windows arrive as — once, and again on every
1247
+ * change. The plane carries the source rate (8 kHz for G.711 since D450,
1248
+ * 16 kHz for AAC through the codec session), and the classifier resamples
1249
+ * anything that is not 16 kHz mono next to the model. A camera whose
1250
+ * format flips mid-subscription (a broker redeploy) is visible here, and
1251
+ * a consumer that stopped receiving what it expected is never silent
1252
+ * (D427).
1253
+ */
1254
+ reportWindowFormat(chunk, sampleCount) {
1255
+ const key = chunk.deviceId ?? GLOBAL_DEVICE_KEY;
1256
+ const format = {
1257
+ sampleRate: chunk.sampleRate,
1258
+ channels: chunk.channels
1259
+ };
1260
+ const previous = this.windowFormats.get(key) ?? null;
1261
+ if (previous !== null && previous.sampleRate === format.sampleRate && previous.channels === format.channels) return;
1262
+ this.windowFormats.set(key, format);
1263
+ const channels = chunk.channels > 0 ? chunk.channels : 1;
1264
+ const windowMs = chunk.sampleRate > 0 ? Math.round(sampleCount / channels / chunk.sampleRate * 1e3) : null;
1265
+ this.log.info("audio window format", {
1266
+ tags: chunk.deviceId !== void 0 ? { deviceId: chunk.deviceId } : void 0,
1267
+ meta: {
1268
+ sampleRate: chunk.sampleRate,
1269
+ channels: chunk.channels,
1270
+ bytes: chunk.data.byteLength,
1271
+ windowMs,
1272
+ classifierResamples: chunk.sampleRate !== CLASSIFIER_SAMPLE_RATE || chunk.channels !== CLASSIFIER_CHANNELS,
1273
+ previous
1274
+ }
1275
+ });
1276
+ }
666
1277
  async classify(chunk) {
667
1278
  const camKey = chunk.deviceId ?? GLOBAL_DEVICE_KEY;
668
1279
  const now = Date.now();
@@ -797,6 +1408,13 @@ var AudioAnalyzerAddon = class extends BaseAddon {
797
1408
  * in-place rebuild in `onConfigChanged` (rebuild only when it genuinely
798
1409
  * flips). The provider owns the (lazy) pipeline itself. */
799
1410
  activeBackend = null;
1411
+ /**
1412
+ * This node's live audio attachments (D461). Held on the ADDON rather than
1413
+ * only inside the provider because shutdown must release the broker's FIFOs
1414
+ * even if the provider never came up — an orphaned audio subscription is
1415
+ * broker DEMAND, not merely a leak.
1416
+ */
1417
+ attachments = null;
800
1418
  constructor() {
801
1419
  super(DEFAULT_AUDIO_ANALYZER_CONFIG);
802
1420
  }
@@ -941,7 +1559,40 @@ var AudioAnalyzerAddon = class extends BaseAddon {
941
1559
  return null;
942
1560
  };
943
1561
  const deviceSettingsPatcher = async (_deviceId, _patch) => {};
944
- this.provider = new AudioAnalyzerProvider(logger, (b) => this.buildPipeline(b), backend, deviceSettingsResolver, deviceContributionResolver, deviceSettingsPatcher, () => this.reprobeAudioEngine());
1562
+ this.attachments = new AudioDeviceAttachments({
1563
+ api: () => this.ctx.api,
1564
+ localNodeId: () => this.ctx.kernel?.localNodeId ?? "hub",
1565
+ logger: logger.child("audio-attach"),
1566
+ analyse: async (chunk, settings) => {
1567
+ const provider = this.provider;
1568
+ if (!provider) return null;
1569
+ return provider.analyseChunk({
1570
+ chunk,
1571
+ settings
1572
+ });
1573
+ },
1574
+ emitResult: (deviceId, frame) => {
1575
+ const nodeId = this.ctx.kernel?.localNodeId ?? "hub";
1576
+ this.ctx.eventBus.emit({
1577
+ id: `audio-inference-${deviceId}-${Date.now()}`,
1578
+ timestamp: /* @__PURE__ */ new Date(),
1579
+ source: {
1580
+ type: "device",
1581
+ id: deviceId,
1582
+ nodeId,
1583
+ addonId: "audio-analyzer",
1584
+ deviceId
1585
+ },
1586
+ category: EventCategory.PipelineAudioInferenceResult,
1587
+ data: {
1588
+ deviceId,
1589
+ frame,
1590
+ nodeId
1591
+ }
1592
+ });
1593
+ }
1594
+ });
1595
+ this.provider = new AudioAnalyzerProvider(logger, (b) => this.buildPipeline(b), backend, deviceSettingsResolver, deviceContributionResolver, deviceSettingsPatcher, () => this.reprobeAudioEngine(), this.attachments);
945
1596
  return [{
946
1597
  capability: audioAnalyzerCapability,
947
1598
  provider: this.provider
@@ -951,6 +1602,8 @@ var AudioAnalyzerAddon = class extends BaseAddon {
951
1602
  }];
952
1603
  }
953
1604
  async onShutdown() {
1605
+ this.attachments?.detachAll();
1606
+ this.attachments = null;
954
1607
  if (this.provider) {
955
1608
  await this.provider.dispose();
956
1609
  this.provider = null;