@camstack/addon-post-analysis 1.2.167 → 1.2.169

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-dGS8z89L.js");
5
+ const require_dist = require("../dist-DQVA7q-T.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -3139,7 +3139,8 @@ var LlmVisionJudge = class {
3139
3139
  prompt: call.prompt,
3140
3140
  images,
3141
3141
  jsonSchema: call.jsonSchema,
3142
- temperature: 0
3142
+ temperature: 0,
3143
+ timeoutMs: call.timeoutMs
3143
3144
  }).catch((err) => ({
3144
3145
  ok: false,
3145
3146
  message: String(err)
@@ -19705,8 +19706,37 @@ var TimelapseRenderError = class extends Error {
19705
19706
  this.reason = reason;
19706
19707
  }
19707
19708
  };
19708
- /** The profile with the most footage over the window; ties broken by name so
19709
- * the same window always renders from the same source. */
19709
+ /**
19710
+ * Profiles from cheapest to dearest, so the source is chosen by COST.
19711
+ *
19712
+ * `CamProfileSchema` is declared dearest-first; this is that order reversed.
19713
+ * A name the enum does not know sorts after all of them, alphabetically, so the
19714
+ * choice stays deterministic for a fleet with a bespoke profile.
19715
+ */
19716
+ var PROFILE_BY_COST = [...require_dist.CamProfileSchema.options].reverse();
19717
+ function profileCost(profile) {
19718
+ const i = PROFILE_BY_COST.indexOf(profile);
19719
+ return i === -1 ? PROFILE_BY_COST.length : i;
19720
+ }
19721
+ /**
19722
+ * The CHEAPEST profile that has footage over the window.
19723
+ *
19724
+ * It used to be the profile with the MOST coverage, ties broken by name — and
19725
+ * that is a rule which picks `high` almost every time, because the high band is
19726
+ * the one that records continuously. On 2026-09-03 it did: a test render of a
19727
+ * ten-hour window chose `profile=high` at 97% coverage, which on this hub means
19728
+ * 4K read from a USB spinning disk. Decoding it made the machine stop answering
19729
+ * SSH and its own web UI for half an hour, and it had to be power-cycled.
19730
+ *
19731
+ * A timelapse is watched accelerated. Resolution buys nothing at 12 fps over a
19732
+ * night, and the cost difference between the bands is one to two orders of
19733
+ * magnitude in both bytes read and frames decoded. So there is no trade to
19734
+ * weigh: take the cheapest band that has anything, every time.
19735
+ *
19736
+ * Coverage no longer decides WHICH — only whether a band qualifies at all. A
19737
+ * band with less footage is still the right source; the gaps show as a shorter
19738
+ * video, not as a reason to read twenty times the bytes.
19739
+ */
19710
19740
  function pickProfile(ranges, window) {
19711
19741
  const byProfile = /* @__PURE__ */ new Map();
19712
19742
  for (const r of ranges) {
@@ -19714,15 +19744,15 @@ function pickProfile(ranges, window) {
19714
19744
  if (list === void 0) byProfile.set(r.profile, [r]);
19715
19745
  else list.push(r);
19716
19746
  }
19717
- let best = null;
19718
- for (const [profile, rows] of [...byProfile].sort((a, b) => a[0].localeCompare(b[0]))) {
19747
+ const ordered = [...byProfile].sort((a, b) => profileCost(a[0]) - profileCost(b[0]) || a[0].localeCompare(b[0]));
19748
+ for (const [profile, rows] of ordered) {
19719
19749
  const coverage = footageCoverage(rows, window.startMs, window.endMs);
19720
- if (best === null || coverage.coveredMs > best.coverage.coveredMs) best = {
19750
+ if (coverage.coveredMs > 0) return {
19721
19751
  profile,
19722
19752
  coverage
19723
19753
  };
19724
19754
  }
19725
- return best;
19755
+ return null;
19726
19756
  }
19727
19757
  /**
19728
19758
  * Is `record` the render this request is about to order?
@@ -27254,9 +27284,29 @@ async function renderTimelapseGif(ports, request) {
27254
27284
  }
27255
27285
  //#endregion
27256
27286
  //#region src/shared/frame/square-subject-crop.ts
27257
- /** Expansion factor applied to the bbox long side to frame the subject with a
27258
- * little breathing room (operator choice: ×1.2). */
27259
- var SQUARE_SUBJECT_CROP_EXPANSION = 1.2;
27287
+ /**
27288
+ * Expansion applied to the bbox long side to frame the subject with a little
27289
+ * breathing room.
27290
+ *
27291
+ * ×1.2 → ×1.05 (operator, 2026-09-03: "riduci e proviamo"). The complaint was
27292
+ * that a best shot carries too much surrounding scene, and the measurement says
27293
+ * where it goes. On 26 148 person samples from camera 617 the median bbox is
27294
+ * 1.44× taller than wide, so the square is driven by HEIGHT and half its width
27295
+ * is empty before the 16:9 extension adds any:
27296
+ *
27297
+ * subject inside the square 48% (waste 52%) ← this factor
27298
+ * square inside the 16:9 56% (waste 44%)
27299
+ * subject delivered 27%
27300
+ *
27301
+ * A prior analysis called the 16:9 extension the dominant waste. For a standing
27302
+ * person it is not: squaring costs more, because it pays for the height twice.
27303
+ * At ×1.05 the subject holds 63% of the square and ~35% of the canvas.
27304
+ *
27305
+ * The floor is 1.0, not lower: the square must CONTAIN the bbox, and detector
27306
+ * boxes hug the visible extent, so some margin is what keeps hair and feet in
27307
+ * shot when the box is a little tight.
27308
+ */
27309
+ var SQUARE_SUBJECT_CROP_EXPANSION = 1.05;
27260
27310
  /**
27261
27311
  * Compute a SQUARE crop region in PIXEL space around the subject bbox.
27262
27312
  *
@@ -33180,6 +33230,9 @@ function isDecisiveImprovement(current, candidate) {
33180
33230
  if (!isSquareContained(candidate) && isSquareContained(current)) return false;
33181
33231
  if (!isSemantic(candidate) && isSemantic(current)) return false;
33182
33232
  if (!candidate.edgeClear && current.edgeClear) return false;
33233
+ const currentSize = current.sizeScore;
33234
+ const candidateSize = candidate.sizeScore;
33235
+ if (currentSize !== void 0 && candidateSize !== void 0 && currentSize > 0 && candidateSize >= currentSize * 1.25) return true;
33183
33236
  return effectiveScore(candidate) >= effectiveScore(current) * DECISIVE_IMPROVEMENT_RATIO;
33184
33237
  }
33185
33238
  /**
@@ -36626,19 +36679,19 @@ var BEST_FACE_POLICY = {
36626
36679
  semanticAware: false
36627
36680
  };
36628
36681
  /**
36629
- * Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
36682
+ * Best OBJECT (CLIP) embedding + its tight crop — the in-memory
36630
36683
  * DECISION (which live frame the embedding row + crop derive from). The
36631
36684
  * durable, cross-restart "if better" gate stays DOWNSTREAM in
36632
36685
  * `ObjectEmbeddingStore.upsertIfBetter` (the documented seam: this selector
36633
- * resets on addon restart, the store row does not). Same values as
36634
- * {@link BEST_FACE_POLICY} today, but a SEPARATE named policy: the two paths
36635
- * may drift apart deliberately without one masquerading as the other.
36686
+ * resets on addon restart, the store row does not). A SEPARATE named policy
36687
+ * from the face path: the two may drift apart deliberately without one
36688
+ * masquerading as the other.
36636
36689
  */
36637
36690
  var BEST_OBJECT_EMBED_POLICY = {
36638
36691
  hysteresis: 0,
36639
36692
  minGapMs: 0,
36640
- edgeAware: false,
36641
- sizeAware: false,
36693
+ edgeAware: true,
36694
+ sizeAware: true,
36642
36695
  semanticAware: false
36643
36696
  };
36644
36697
  /**
@@ -39437,6 +39490,12 @@ var TrackStore = class {
39437
39490
  upsert(params) {
39438
39491
  const existing = this.active.get(params.trackId);
39439
39492
  if (existing) {
39493
+ if (params.matched === false) {
39494
+ existing.state = params.state;
39495
+ for (const z of params.zones) if (!existing.zonesVisited.includes(z)) existing.zonesVisited.push(z);
39496
+ if (!existing.classes.includes(params.className)) existing.classes.push(params.className);
39497
+ return existing;
39498
+ }
39440
39499
  const last = existing.positions[existing.positions.length - 1];
39441
39500
  const dist = last ? Math.sqrt((params.position.x - last.x) ** 2 + (params.position.y - last.y) ** 2) : 0;
39442
39501
  existing.lastSeen = params.timestamp;
@@ -43985,6 +44044,36 @@ var AnalyticsQueryFacade = class {
43985
44044
  //#region src/pipeline-analytics/audio-detection-settings.ts
43986
44045
  var AudioDetectionSettingsSchema = require_dist.object({
43987
44046
  classificationMinScore: require_dist.number().min(0).max(1).default(.6),
44047
+ /**
44048
+ * Macro classes allowed to produce an audio EVENT on this camera. Empty =
44049
+ * no policy, every class passes — so an upgrade changes nothing until an
44050
+ * operator states a policy.
44051
+ *
44052
+ * This belongs to the event generator, NOT to the classifier: the analyzer
44053
+ * keeps classifying everything it hears, `audioLabels` on the track keeps
44054
+ * carrying the full picture, and only the decision to MATERIALIZE an event
44055
+ * is narrowed. Raising a threshold inside the classifier would instead make
44056
+ * the sound invisible to every consumer at once.
44057
+ */
44058
+ audioAllowedClasses: require_dist.array(require_dist.string()).default([]),
44059
+ /**
44060
+ * Corroboration window on the EVENT, per class: this many occurrences of
44061
+ * the same sound must land inside `audioConfirmWindowSec` before it becomes
44062
+ * an event. `1` = no window.
44063
+ *
44064
+ * Default 2-in-10s, set by the operator on 2026-09-03 after measuring 26
44065
+ * audio tracks in 24 h: the single-frame classification is the dominant
44066
+ * false positive, and a sound that is real is heard again within ten
44067
+ * seconds. A camera that wants every occurrence sets `1`.
44068
+ *
44069
+ * The Notification Center already has this exact brake for audio RULES
44070
+ * (`confirmHits` / `confirmWindowSec`). This is the same idea one level
44071
+ * earlier, so a camera can stop MAKING the single-frame events instead of
44072
+ * only declining to notify about them.
44073
+ */
44074
+ audioConfirmHits: require_dist.number().int().min(1).default(2),
44075
+ /** Width of the corroboration window. Ignored when `audioConfirmHits` is 1. */
44076
+ audioConfirmWindowSec: require_dist.number().positive().default(10),
43988
44077
  levelDeviationDb: require_dist.number().positive().default(10),
43989
44078
  levelWindowSec: require_dist.number().positive().default(10),
43990
44079
  levelMaxWaitSec: require_dist.number().positive().default(20),
@@ -44025,8 +44114,17 @@ var AudioDetectionSettingsSchema = require_dist.object({
44025
44114
  audioEpisodeOpenHoldSec: require_dist.number().nonnegative().default(1.5),
44026
44115
  /** Class must be absent this long before the episode closes. */
44027
44116
  audioEpisodeCloseGapSec: require_dist.number().positive().default(12),
44028
- /** After close, the same class cannot reopen until this elapses. */
44029
- audioEpisodeCooldownSec: require_dist.number().nonnegative().default(30)
44117
+ /** After close, the same class cannot reopen until this elapses.
44118
+ *
44119
+ * This is the minimum spacing between two SYNTHETIC EVENTS (the episode's
44120
+ * event + timeline track) of the same sound on one camera — NOT a
44121
+ * notification floor. Notification spacing is the rule's own
44122
+ * `throttle.cooldownSec`, which already defaults to 60 s and is keyed
44123
+ * per-device; the two are independent brakes on different objects.
44124
+ *
44125
+ * Raised 30 → 60 s on 2026-09-03 by the operator, alongside the 2-in-10s
44126
+ * corroboration window above. */
44127
+ audioEpisodeCooldownSec: require_dist.number().nonnegative().default(60)
44030
44128
  });
44031
44129
  var AUDIO_DETECTION_DEFAULTS = AudioDetectionSettingsSchema.parse({});
44032
44130
  /**
@@ -44203,14 +44301,45 @@ function observeAudioEpisode(state, sample, knobs) {
44203
44301
  };
44204
44302
  }
44205
44303
  //#endregion
44304
+ //#region src/pipeline-analytics/audio-confirm-window.ts
44305
+ function emptyAudioConfirmState() {
44306
+ return { hitsByClass: /* @__PURE__ */ new Map() };
44307
+ }
44308
+ function observeAudioConfirm(state, input, cfg) {
44309
+ if (cfg.confirmHits <= 1) return {
44310
+ confirmed: true,
44311
+ nextState: state
44312
+ };
44313
+ const cutoff = input.timestampMs - cfg.confirmWindowSec * 1e3;
44314
+ const kept = [...(state.hitsByClass.get(input.className) ?? []).filter((at) => at > cutoff), input.timestampMs];
44315
+ const next = new Map(state.hitsByClass);
44316
+ if (kept.length < cfg.confirmHits) {
44317
+ next.set(input.className, kept);
44318
+ return {
44319
+ confirmed: false,
44320
+ nextState: { hitsByClass: next }
44321
+ };
44322
+ }
44323
+ next.delete(input.className);
44324
+ return {
44325
+ confirmed: true,
44326
+ nextState: { hitsByClass: next }
44327
+ };
44328
+ }
44329
+ //#endregion
44206
44330
  //#region src/pipeline-analytics/audio-event-router.ts
44207
44331
  /**
44208
44332
  * Decide whether an audio frame is a (meaningful) classification event or a
44209
44333
  * level event. `silence` is never a classification — it falls to the level
44210
44334
  * path so only a genuine loudness peak/heartbeat can surface it.
44335
+ *
44336
+ * A class the operator did not allow takes the SAME route as a class that
44337
+ * failed the score: the level path. Both mean "not a classification event on
44338
+ * this camera", and giving the allowlist its own harder outcome (a silent
44339
+ * drop) would be a second arithmetic for one question.
44211
44340
  */
44212
44341
  function classifyAudioFrame(top, cfg) {
44213
- if (top && top.className !== "silence" && top.score >= cfg.classificationMinScore) return {
44342
+ if ((cfg.allowedClasses.length === 0 || cfg.allowedClasses.includes(top?.className ?? "")) && top && top.className !== "silence" && top.score >= cfg.classificationMinScore) return {
44214
44343
  kind: "classification",
44215
44344
  className: top.className,
44216
44345
  score: top.score
@@ -59357,6 +59486,81 @@ function hasWrappedDetection(entries) {
59357
59486
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
59358
59487
  }
59359
59488
  //#endregion
59489
+ //#region src/pipeline-analytics/runtime/native-crop-dedupe.ts
59490
+ var NativeCropDedupe = class {
59491
+ config;
59492
+ entries = /* @__PURE__ */ new Map();
59493
+ fetched = 0;
59494
+ coalesced = 0;
59495
+ reused = 0;
59496
+ /**
59497
+ * What this actually saved, since the last read.
59498
+ *
59499
+ * Shipped WITH the optimisation, not after it: without a counter the only
59500
+ * evidence available is the process's RSS, which is allocator residue that
59501
+ * ratchets within a boot and cannot answer "did the dedupe fire". An
59502
+ * optimisation nobody can measure is a claim, not a fix.
59503
+ */
59504
+ drainCounts() {
59505
+ const counts = {
59506
+ fetched: this.fetched,
59507
+ coalesced: this.coalesced,
59508
+ reused: this.reused
59509
+ };
59510
+ this.fetched = 0;
59511
+ this.coalesced = 0;
59512
+ this.reused = 0;
59513
+ return counts;
59514
+ }
59515
+ constructor(config) {
59516
+ this.config = config;
59517
+ }
59518
+ /** Retained entries — for the bound's own test, and for metrics. */
59519
+ size() {
59520
+ return this.entries.size;
59521
+ }
59522
+ async run(handle, region, maxWidth, fetch) {
59523
+ const key = `${handle.shmId}#${handle.slot}#${handle.seq}|${region.x},${region.y},${region.w},${region.h}|${maxWidth ?? "full"}`;
59524
+ const existing = this.entries.get(key);
59525
+ if (existing !== void 0) if (existing.value !== void 0 && existing.at !== void 0) {
59526
+ if (this.config.now() - existing.at < this.config.ttlMs) {
59527
+ this.reused += 1;
59528
+ return existing.value;
59529
+ }
59530
+ this.entries.delete(key);
59531
+ } else {
59532
+ this.coalesced += 1;
59533
+ return existing.pending;
59534
+ }
59535
+ this.fetched += 1;
59536
+ const pending = fetch();
59537
+ const entry = { pending };
59538
+ this.entries.set(key, entry);
59539
+ try {
59540
+ const result = await pending;
59541
+ if (result === null) {
59542
+ this.entries.delete(key);
59543
+ return null;
59544
+ }
59545
+ entry.value = result;
59546
+ entry.at = this.config.now();
59547
+ this.evictOverflow();
59548
+ return result;
59549
+ } catch (err) {
59550
+ this.entries.delete(key);
59551
+ throw err;
59552
+ }
59553
+ }
59554
+ /** Oldest-first, and only ever over the ceiling. */
59555
+ evictOverflow() {
59556
+ while (this.entries.size > this.config.maxEntries) {
59557
+ const oldest = this.entries.keys().next();
59558
+ if (oldest.done === true) return;
59559
+ this.entries.delete(oldest.value);
59560
+ }
59561
+ }
59562
+ };
59563
+ //#endregion
59360
59564
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
59361
59565
  function createCaptureCrop(deps) {
59362
59566
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -59537,9 +59741,13 @@ function createNativeFrameTransport(deps) {
59537
59741
  const now = Date.now();
59538
59742
  if (now - lastCropMetricAt < NATIVE_CROP_METRIC_INTERVAL_MS) return;
59539
59743
  lastCropMetricAt = now;
59744
+ const dedupe = cropDedupe.drainCounts();
59540
59745
  cropMetricLogger.info("native-crop window", { meta: {
59541
59746
  nativeHits,
59542
- detectionFrameFallbacks: nativeFallbacks
59747
+ detectionFrameFallbacks: nativeFallbacks,
59748
+ wireFetches: dedupe.fetched,
59749
+ coalesced: dedupe.coalesced,
59750
+ reusedInWindow: dedupe.reused
59543
59751
  } });
59544
59752
  };
59545
59753
  const requestNativeCrop = async (frameHandle, bbox, maxWidth, normalize) => {
@@ -59553,6 +59761,16 @@ function createNativeFrameTransport(deps) {
59553
59761
  if (!native || native.width <= 0 || native.height <= 0) return null;
59554
59762
  return await normalize(native);
59555
59763
  };
59764
+ /**
59765
+ * Bounded and brief on purpose. Coalescing costs nothing; the 1.5 s window
59766
+ * holds at most 12 crops in THIS process to keep far more than that off
59767
+ * hub-main. It must never grow into the memory it was written to save.
59768
+ */
59769
+ const cropDedupe = new NativeCropDedupe({
59770
+ now: () => Date.now(),
59771
+ ttlMs: 1500,
59772
+ maxEntries: 12
59773
+ });
59556
59774
  const tryRequestNativeCrop = async (frameHandle, bbox, maxWidth, normalize) => {
59557
59775
  try {
59558
59776
  return await requestNativeCrop(frameHandle, bbox, maxWidth, normalize);
@@ -59561,7 +59779,7 @@ function createNativeFrameTransport(deps) {
59561
59779
  return null;
59562
59780
  }
59563
59781
  };
59564
- const fetchNativeCropJpeg = async (frameHandle, paddedNorm, maxWidth) => tryRequestNativeCrop(frameHandle, paddedNorm, maxWidth, cropReplyToJpeg);
59782
+ const fetchNativeCropJpeg = async (frameHandle, paddedNorm, maxWidth) => cropDedupe.run(frameHandle, paddedNorm, maxWidth, () => tryRequestNativeCrop(frameHandle, paddedNorm, maxWidth, cropReplyToJpeg));
59565
59783
  const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
59566
59784
  return (await fetchNativeCropJpeg(frameHandle, paddedNorm, maxWidth))?.jpeg ?? null;
59567
59785
  };
@@ -61241,8 +61459,15 @@ var EventMediaDispatcher = class {
61241
61459
  * OWN box burned onto the NATIVE full frame downscaled to
61242
61460
  * {@link SNAPSHOT_MAX_WIDTH}. On a native miss it falls back to the ≤640
61243
61461
  * detection frame (`frameData`) downscaled to the same cap with a `logger.warn`
61244
- * — an honest LOWER-res frame, NEVER an upscale. Returns the JPEG, or `null`
61245
- * only if the sharp encode itself throws.
61462
+ * — an honest LOWER-res frame, NEVER an upscale.
61463
+ *
61464
+ * Returns BOTH renderings of the same surface: the boxed tile the timeline
61465
+ * wants, and its CLEAN twin (`drawBoxedFrame(…, [])` — encode only, the same
61466
+ * call `keyFrame` uses). One extra encode, no second fetch and no second
61467
+ * decode, because the notification ladder needs a densely-written whole-scene
61468
+ * frame WITHOUT the rectangle and there was none: 19 of 38 measured
61469
+ * `frame:'full'` deliveries carried the box. `null` only if a sharp encode
61470
+ * throws.
61246
61471
  */
61247
61472
  async boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, ownerId, bbox, label) {
61248
61473
  let nf = null;
@@ -63076,6 +63301,36 @@ function buildGlobalSettingsSchema(cap) {
63076
63301
  step: .05,
63077
63302
  default: AUDIO_DETECTION_DEFAULTS.classificationMinScore
63078
63303
  },
63304
+ {
63305
+ type: "multiselect",
63306
+ key: "audioAllowedClasses",
63307
+ label: "Event classes",
63308
+ description: "Which sounds may become an event on this camera. Leave empty to allow every class. The classifier is untouched — it keeps hearing and labelling everything; this only narrows what is worth an event and a notification here.",
63309
+ options: require_dist.AUDIO_MACRO_LABELS.filter((l) => l.id !== "silence").map((l) => ({
63310
+ value: l.id,
63311
+ label: l.name
63312
+ })),
63313
+ default: AUDIO_DETECTION_DEFAULTS.audioAllowedClasses
63314
+ },
63315
+ {
63316
+ type: "number",
63317
+ key: "audioConfirmHits",
63318
+ label: "Occurrences to confirm",
63319
+ description: "How many times the same sound must be heard inside the window below before it becomes an event. 1 disables the window — every occurrence is an event.",
63320
+ min: 1,
63321
+ step: 1,
63322
+ default: AUDIO_DETECTION_DEFAULTS.audioConfirmHits
63323
+ },
63324
+ {
63325
+ type: "number",
63326
+ key: "audioConfirmWindowSec",
63327
+ label: "Confirmation window",
63328
+ description: "Width of the corroboration window. Occurrences are counted per class, so two different sounds never confirm each other. Ignored when occurrences is 1.",
63329
+ min: 1,
63330
+ step: 1,
63331
+ default: AUDIO_DETECTION_DEFAULTS.audioConfirmWindowSec,
63332
+ unit: "s"
63333
+ },
63079
63334
  {
63080
63335
  type: "number",
63081
63336
  key: "levelDeviationDb",
@@ -65922,6 +66177,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65922
66177
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
65923
66178
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
65924
66179
  levelStateByDevice = /* @__PURE__ */ new Map();
66180
+ /** Per-device corroboration window for classified audio (see
66181
+ * `audio-confirm-window.ts`). Torn down wherever `levelStateByDevice` is —
66182
+ * a stale window on a removed device would confirm on its first frame back. */
66183
+ audioConfirmByDevice = /* @__PURE__ */ new Map();
65925
66184
  /**
65926
66185
  * Last ms a spatial track was alive on a device, any plane. Written on the
65927
66186
  * frame path (one `Map.set` per processed frame), read O(1) by the audio-
@@ -67436,7 +67695,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67436
67695
  mimeType: i.mimeType
67437
67696
  })),
67438
67697
  jsonSchema: request.jsonSchema,
67439
- temperature: request.temperature
67698
+ temperature: request.temperature,
67699
+ ...request.timeoutMs !== void 0 ? { timeoutMs: request.timeoutMs } : {}
67440
67700
  });
67441
67701
  return result.ok ? {
67442
67702
  ok: true,
@@ -68342,6 +68602,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68342
68602
  this.overlaySynthesisWarnAt.delete(data.deviceId);
68343
68603
  this.forgetDeviceProcessors(data.deviceId);
68344
68604
  this.levelStateByDevice.delete(data.deviceId);
68605
+ this.audioConfirmByDevice.delete(data.deviceId);
68345
68606
  this.settingsCaches.clearDevice(data.deviceId);
68346
68607
  this.rawDeviceBlobMirror.invalidate(data.deviceId);
68347
68608
  }
@@ -68360,6 +68621,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68360
68621
  this.overlaySynthesisWarnAt.delete(deviceId);
68361
68622
  this.forgetDeviceProcessors(deviceId);
68362
68623
  this.levelStateByDevice.delete(deviceId);
68624
+ this.audioConfirmByDevice.delete(deviceId);
68363
68625
  this.lastTrackActivityMs.delete(deviceId);
68364
68626
  this.motionEventSnapshots?.forgetDevice(deviceId);
68365
68627
  this.audioEpisodeByDevice.delete(deviceId);
@@ -68576,7 +68838,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68576
68838
  mimeType: image.mimeType
68577
68839
  })),
68578
68840
  jsonSchema: i.jsonSchema,
68579
- temperature: i.temperature
68841
+ temperature: i.temperature,
68842
+ ...i.timeoutMs !== void 0 ? { timeoutMs: i.timeoutMs } : {}
68580
68843
  }),
68581
68844
  cancelVision: async (requestId) => {
68582
68845
  await api.llm.cancel.mutate({ requestId });
@@ -68871,6 +69134,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68871
69134
  this.nodeLiveness.clear();
68872
69135
  this.gatedNodeByDevice.clear();
68873
69136
  this.levelStateByDevice.clear();
69137
+ this.audioConfirmByDevice.clear();
68874
69138
  this.settingsCaches.clearAll();
68875
69139
  this.rawDeviceBlobMirror.invalidateAll();
68876
69140
  this.rawGlobalStoreMirror.invalidate();
@@ -69038,6 +69302,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
69038
69302
  trackId: t.trackId,
69039
69303
  deviceId,
69040
69304
  className: t.className,
69305
+ matched: t.matchedThisFrame !== false,
69041
69306
  ...t.labelPatch !== void 0 ? { labelPatch: t.labelPatch } : {},
69042
69307
  timestamp: result.timestamp,
69043
69308
  position: {
@@ -70885,12 +71150,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70885
71150
  this.notificationCenter?.observeAudio(deviceId, {
70886
71151
  timestampMs: timestamp,
70887
71152
  ...level?.dbfs !== void 0 ? { dbfs: level.dbfs } : {},
70888
- labels: (frame.detections ?? []).filter((d) => d.score >= settings.classificationMinScore).map((d) => d.macroClass)
71153
+ labels: (frame.detections ?? []).filter((d) => d.score >= settings.classificationMinScore).map((d) => d.macroClass).filter((macroClass) => settings.audioAllowedClasses.length === 0 || settings.audioAllowedClasses.includes(macroClass))
70889
71154
  });
70890
71155
  const route = classifyAudioFrame(topClassification ? {
70891
71156
  className: topClassification.macroClass,
70892
71157
  score: topClassification.score
70893
- } : void 0, { classificationMinScore: settings.classificationMinScore });
71158
+ } : void 0, {
71159
+ classificationMinScore: settings.classificationMinScore,
71160
+ allowedClasses: settings.audioAllowedClasses
71161
+ });
70894
71162
  if (route.kind === "classification") {
70895
71163
  if (!topClassification) {
70896
71164
  this.ctx.logger.warn("classifyAudioFrame returned classification but topClassification is null", {
@@ -70899,6 +71167,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70899
71167
  });
70900
71168
  return;
70901
71169
  }
71170
+ const confirm = observeAudioConfirm(this.audioConfirmByDevice.get(deviceId) ?? emptyAudioConfirmState(), {
71171
+ className: route.className,
71172
+ timestampMs: timestamp
71173
+ }, {
71174
+ confirmHits: settings.audioConfirmHits,
71175
+ confirmWindowSec: settings.audioConfirmWindowSec
71176
+ });
71177
+ this.audioConfirmByDevice.set(deviceId, confirm.nextState);
71178
+ if (!confirm.confirmed) {
71179
+ this.ctx.logger.debug("audio event held for corroboration", {
71180
+ tags: { deviceId },
71181
+ meta: {
71182
+ deviceId,
71183
+ className: route.className,
71184
+ confirmHits: settings.audioConfirmHits,
71185
+ confirmWindowSec: settings.audioConfirmWindowSec
71186
+ }
71187
+ });
71188
+ return;
71189
+ }
70902
71190
  const classified = {
70903
71191
  deviceId,
70904
71192
  timestamp,