@camstack/addon-pipeline 1.2.222 → 1.2.224

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.
@@ -9405,42 +9405,14 @@ function extractQuoted(header, key) {
9405
9405
  const unquotedRegex = new RegExp(`${key}=([^,\\s]+)`, "i");
9406
9406
  return header.match(unquotedRegex)?.[1];
9407
9407
  }
9408
- //#endregion
9409
- //#region src/stream-broker/stream-broker/audio-chunk-plane.ts
9410
- /**
9411
- * `AudioChunkPlane` — the broker's serialisable decoded audio-chunk surface
9412
- * (Phase 5 / D9).
9413
- *
9414
- * This is the poll-based replacement for `StreamBroker.onDecodedAudioChunk`'s
9415
- * live-object callback path. A live callback cannot cross a process boundary;
9416
- * once the `pipeline` group is dissolved (Task 8) the audio consumer
9417
- * (`pipeline-orchestrator`) runs in a different process from the broker, so
9418
- * audio delivery must go over tRPC like the video frame plane.
9419
- *
9420
- * ## Why no shared memory
9421
- *
9422
- * Unlike video frames (~6 MB at 1080p RGB), a decoded audio chunk is tiny — a
9423
- * ~500ms `AudioCodecSession` window is a few KB of Float32 PCM. Tiny payloads
9424
- * serialise over tRPC cheaply, so audio ships its bytes INLINE on the RPC
9425
- * wire; there is no shm ring.
9426
- *
9427
- * ## FIFO / no-drop queue policy
9428
- *
9429
- * Audio wants FIFO arrival order with no latest-wins drop: a missing chunk is
9430
- * an audible gap and breaks the audio analyzer's fixed-length analysis
9431
- * window. Each subscription therefore owns a generously-sized FIFO queue
9432
- * (`AUDIO_QUEUE_CAPACITY`). Chunks arrive roughly every
9433
- * `AudioCodecSession`-window (~500ms), so the queue buffers tens of seconds
9434
- * of audio — a healthy poll loop never fills it. The queue only drops its
9435
- * OLDEST chunk if a truly stalled consumer lets it overflow (a bounded safety
9436
- * valve, not the steady-state behaviour).
9437
- */
9438
9408
  /**
9439
- * Per-subscription FIFO capacity. A chunk is a few KB and arrives ~every
9440
- * 500ms, so 64 buffers ~30s of audio between two `pullAudioChunks` polls
9441
- * without unbounded growth.
9409
+ * Re-state a standing overflow at most this often (D465). The line fires when a
9410
+ * subscription STARTS overwriting, and again every 30 s while it still is — a
9411
+ * per-chunk warn is ~25 writes/s per camera of synchronous winston on the
9412
+ * broker's event loop, which is the cost the `pullPcm` warn coalescing exists
9413
+ * to avoid.
9442
9414
  */
9443
- var AUDIO_QUEUE_CAPACITY = 64;
9415
+ var OVERFLOW_RELOG_MS = 3e4;
9444
9416
  /**
9445
9417
  * How long a subscription may go without a `pull` before the plane releases it
9446
9418
  * (D461).
@@ -9511,10 +9483,13 @@ var AudioChunkPlane = class {
9511
9483
  id: subscriptionId,
9512
9484
  tag: input?.tag ?? "unknown",
9513
9485
  subscribedAt: Date.now(),
9514
- queue: new RingBuffer(AUDIO_QUEUE_CAPACITY),
9486
+ queue: new RingBuffer(64),
9515
9487
  accept: new Set(input?.accept ?? []),
9516
9488
  servedKey: null,
9517
9489
  chunksDelivered: 0,
9490
+ chunksDropped: 0,
9491
+ overflowing: false,
9492
+ lastOverflowLogAt: 0,
9518
9493
  lastPulledAt: Date.now()
9519
9494
  };
9520
9495
  this.subscriptions.set(subscriptionId, subscription);
@@ -9566,6 +9541,7 @@ var AudioChunkPlane = class {
9566
9541
  leaseMs: AUDIO_SUBSCRIPTION_IDLE_MS,
9567
9542
  queued: subscription.queue.size,
9568
9543
  chunksDelivered: subscription.chunksDelivered,
9544
+ chunksDropped: subscription.chunksDropped,
9569
9545
  hint: "the consumer process died without detaching — its subscription was holding this broker in demand"
9570
9546
  } });
9571
9547
  }
@@ -9601,13 +9577,47 @@ var AudioChunkPlane = class {
9601
9577
  for (const subscription of this.subscriptions.values()) {
9602
9578
  if (sourceFormat === "f32le" || subscription.accept.has(sourceFormat)) {
9603
9579
  this.noteServed(subscription, sourceFormat, sourceFormat);
9604
- subscription.queue.push(chunk);
9580
+ this.enqueue(subscription, chunk);
9605
9581
  continue;
9606
9582
  }
9607
9583
  expanded ??= expandChunk(chunk, sourceFormat);
9608
9584
  this.noteServed(subscription, sourceFormat, "f32le");
9609
- subscription.queue.push(expanded);
9585
+ this.enqueue(subscription, expanded);
9586
+ }
9587
+ }
9588
+ /**
9589
+ * Push one chunk into a subscription's FIFO and NAME the loss when the FIFO
9590
+ * had no room (D465).
9591
+ *
9592
+ * `RingBuffer.push` overwrites its oldest entry. Every docblock on this path
9593
+ * called that "a bounded safety valve, not the steady-state behaviour" and
9594
+ * nothing ever said when the valve opened, so a consumer polling too slowly
9595
+ * for its producer lost audio permanently and in silence — the exact shape
9596
+ * CLAUDE.md forbids. The realistic cause is a cadence chosen for the
9597
+ * BOUNDARY rather than for the producer's chunk rate: 500 ms of a ~42 ms AAC
9598
+ * chunk is ~12 chunks against a batch that used to drain 8.
9599
+ */
9600
+ enqueue(subscription, chunk) {
9601
+ const wasFull = subscription.queue.size >= 64;
9602
+ subscription.queue.push(chunk);
9603
+ if (!wasFull) {
9604
+ subscription.overflowing = false;
9605
+ return;
9610
9606
  }
9607
+ subscription.chunksDropped += 1;
9608
+ const now = Date.now();
9609
+ const isNewEpisode = !subscription.overflowing;
9610
+ subscription.overflowing = true;
9611
+ if (!isNewEpisode && now - subscription.lastOverflowLogAt < OVERFLOW_RELOG_MS) return;
9612
+ subscription.lastOverflowLogAt = now;
9613
+ this.logger?.warn("audio-chunk subscription OVERFLOW — oldest audio overwritten", { meta: {
9614
+ subscriptionId: subscription.id,
9615
+ tag: subscription.tag,
9616
+ capacity: 64,
9617
+ chunksDropped: subscription.chunksDropped,
9618
+ chunksDelivered: subscription.chunksDelivered,
9619
+ hint: "the consumer is pulling slower than this camera produces — its poll cadence or drain batch is wrong for the producer, not the boundary"
9620
+ } });
9611
9621
  }
9612
9622
  /**
9613
9623
  * Run the idle-lease sweep on a cadence rather than on every chunk. A quarter
@@ -9665,10 +9675,6 @@ var AudioChunkPlane = class {
9665
9675
  this.subscriptions.clear();
9666
9676
  }
9667
9677
  };
9668
- //#endregion
9669
- //#region src/stream-broker/stream-broker/audio-codec-session.ts
9670
- var POLL_INTERVAL_MS$1 = 40;
9671
- var MAX_PCM_PER_POLL = 16;
9672
9678
  var RTP_HEADER_LENGTH = 12;
9673
9679
  var WARN_THROTTLE_MS$1 = 5e3;
9674
9680
  var SESSION_NOT_FOUND_RE$1 = /decode session\b.*\bnot found\b/;
@@ -9680,19 +9686,23 @@ var AudioCodecSession = class {
9680
9686
  cfg;
9681
9687
  emit;
9682
9688
  logger;
9689
+ realtimeDemand;
9683
9690
  sessionId = null;
9684
9691
  sessionNodeId = null;
9685
9692
  creating = null;
9686
9693
  pollTimer = null;
9694
+ /** The cadence the loop last armed, so a change can be NAMED once (D465). */
9695
+ pollIntervalMs = null;
9687
9696
  closed = false;
9688
9697
  packetsIn = 0;
9689
9698
  packetsDropped = 0;
9690
9699
  warnState = /* @__PURE__ */ new Map();
9691
- constructor(api, cfg, emit, logger) {
9700
+ constructor(api, cfg, emit, logger, realtimeDemand) {
9692
9701
  this.api = api;
9693
9702
  this.cfg = cfg;
9694
9703
  this.emit = emit;
9695
9704
  this.logger = logger;
9705
+ this.realtimeDemand = realtimeDemand;
9696
9706
  }
9697
9707
  processPacket(rtpData) {
9698
9708
  if (this.closed) return;
@@ -9760,7 +9770,7 @@ var AudioCodecSession = class {
9760
9770
  if (this.creating) await this.creating;
9761
9771
  if (this.closed) return;
9762
9772
  if (this.pollTimer) {
9763
- clearInterval(this.pollTimer);
9773
+ clearTimeout(this.pollTimer);
9764
9774
  this.pollTimer = null;
9765
9775
  }
9766
9776
  if (!this.sessionId) return;
@@ -9782,7 +9792,7 @@ var AudioCodecSession = class {
9782
9792
  if (this.closed) return;
9783
9793
  this.closed = true;
9784
9794
  if (this.pollTimer) {
9785
- clearInterval(this.pollTimer);
9795
+ clearTimeout(this.pollTimer);
9786
9796
  this.pollTimer = null;
9787
9797
  }
9788
9798
  if (this.sessionId) {
@@ -9848,11 +9858,43 @@ var AudioCodecSession = class {
9848
9858
  })();
9849
9859
  return this.creating;
9850
9860
  }
9861
+ /**
9862
+ * The cadence this tick should use. Re-read every tick rather than latched at
9863
+ * arm time: a WebRTC session attaching mid-stream must be served at
9864
+ * {@link REALTIME_POLL_INTERVAL_MS}, and it costs at most one idle interval
9865
+ * (400 ms) to notice — against an ICE/DTLS handshake measured in seconds.
9866
+ */
9867
+ currentPollIntervalMs() {
9868
+ if (!this.realtimeDemand) return 40;
9869
+ return this.realtimeDemand() ? 40 : 400;
9870
+ }
9851
9871
  startPollLoop() {
9852
9872
  if (this.pollTimer || this.closed) return;
9853
- this.pollTimer = setInterval(() => {
9854
- this.pollOnce();
9855
- }, POLL_INTERVAL_MS$1);
9873
+ this.scheduleNextPoll();
9874
+ }
9875
+ /**
9876
+ * Self-scheduling tick. `setInterval` cannot change period, and the period is
9877
+ * now a property of who is listening (D465), so the loop re-arms itself.
9878
+ */
9879
+ scheduleNextPoll() {
9880
+ if (this.closed || !this.sessionId) return;
9881
+ const intervalMs = this.currentPollIntervalMs();
9882
+ if (intervalMs !== this.pollIntervalMs) {
9883
+ const previous = this.pollIntervalMs;
9884
+ this.pollIntervalMs = intervalMs;
9885
+ this.logger?.info("audio-codec: poll cadence", { meta: {
9886
+ intervalMs,
9887
+ previousMs: previous,
9888
+ reason: intervalMs === 40 ? "realtime-consumer" : "plane-only",
9889
+ maxPcmPerPoll: 32
9890
+ } });
9891
+ }
9892
+ this.pollTimer = setTimeout(() => {
9893
+ this.pollTimer = null;
9894
+ this.pollOnce().finally(() => {
9895
+ this.scheduleNextPoll();
9896
+ });
9897
+ }, intervalMs);
9856
9898
  if (typeof this.pollTimer.unref === "function") this.pollTimer.unref();
9857
9899
  }
9858
9900
  async pollOnce() {
@@ -9863,7 +9905,7 @@ var AudioCodecSession = class {
9863
9905
  const pcms = await this.api.pullPcm({
9864
9906
  sessionId: sid,
9865
9907
  ...nodeId ? { nodeId } : {},
9866
- maxCount: MAX_PCM_PER_POLL
9908
+ maxCount: 32
9867
9909
  });
9868
9910
  for (const pcm of pcms) this.appendPcm(pcm);
9869
9911
  } catch (err) {
@@ -9907,7 +9949,7 @@ var AudioCodecSession = class {
9907
9949
  this.sessionId = null;
9908
9950
  this.sessionNodeId = null;
9909
9951
  if (this.pollTimer) {
9910
- clearInterval(this.pollTimer);
9952
+ clearTimeout(this.pollTimer);
9911
9953
  this.pollTimer = null;
9912
9954
  }
9913
9955
  this.warnThrottled("audio-codec: session reaped upstream — will re-create on next packet", `staleSessionId=${staleSid}`);
@@ -12615,7 +12657,7 @@ var StreamBroker = class StreamBroker {
12615
12657
  this.logger?.debug("audio-codec: prior session close failed", { meta: { error: errMsg(err) } });
12616
12658
  });
12617
12659
  }
12618
- this.audioCodecSession = new AudioCodecSession(this.audioCodecApi, cfg, this.buildAudioCodecEmitCallback(), this.logger?.child("audio-codec"));
12660
+ this.audioCodecSession = new AudioCodecSession(this.audioCodecApi, cfg, this.buildAudioCodecEmitCallback(), this.logger?.child("audio-codec"), () => this.encodedCallbacks.size > 0);
12619
12661
  this.logger?.info("push-audio: decode session armed", { meta: {
12620
12662
  codec: cfg.codec,
12621
12663
  sourceSampleRate: cfg.sourceSampleRate,
@@ -13821,7 +13863,7 @@ var StreamBroker = class StreamBroker {
13821
13863
  this.logger?.debug("audio-codec: prior RTSP session close failed", { meta: { error: errMsg(err) } });
13822
13864
  });
13823
13865
  }
13824
- this.audioCodecSession = new AudioCodecSession(this.audioCodecApi, capCfg, this.buildAudioCodecEmitCallback(), this.logger?.child("audio-codec"));
13866
+ this.audioCodecSession = new AudioCodecSession(this.audioCodecApi, capCfg, this.buildAudioCodecEmitCallback(), this.logger?.child("audio-codec"), () => this.encodedCallbacks.size > 0);
13825
13867
  this.logger?.info("audio-codec: decode session armed", { meta: {
13826
13868
  codec: capCfg.codec,
13827
13869
  sourceSampleRate: capCfg.sourceSampleRate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.222",
3
+ "version": "1.2.224",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",