@camstack/addon-post-analysis 1.2.101 → 1.2.102

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.
@@ -1,4 +1,4 @@
1
- import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-ecY_-nDx.mjs";
1
+ import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-CZm8slPB.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -8352,8 +8352,11 @@ var en_default = {
8352
8352
  "one": "{{count}} addon update available",
8353
8353
  "other": "{{count}} addon updates available"
8354
8354
  },
8355
+ "system.addon-update-available.title.onNode": {
8356
+ "one": "{{count}} addon update available on {{nodeId}}",
8357
+ "other": "{{count}} addon updates available on {{nodeId}}"
8358
+ },
8355
8359
  "system.addon-update-available.body": "{{list}}",
8356
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8357
8360
  "system.server-update-available.title": {
8358
8361
  "one": "Server update available on {{count}} node",
8359
8362
  "other": "Server update available on {{count}} nodes"
@@ -8368,6 +8371,7 @@ var en_default = {
8368
8371
  "system.addon-updated.body": "{{list}}",
8369
8372
  "system.server-updated.title": "Server updated",
8370
8373
  "system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
8374
+ "system.server-updated.body.toOnly": "{{packageName}} on {{nodeId}} is now {{toVersion}}",
8371
8375
  "system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
8372
8376
  "system.export-completed.title": "Export ready",
8373
8377
  "system.export-completed.body": "The footage export is ready",
@@ -8566,8 +8570,11 @@ var it_default = {
8566
8570
  "one": "{{count}} aggiornamento addon disponibile",
8567
8571
  "other": "{{count}} aggiornamenti addon disponibili"
8568
8572
  },
8573
+ "system.addon-update-available.title.onNode": {
8574
+ "one": "{{count}} aggiornamento addon disponibile su {{nodeId}}",
8575
+ "other": "{{count}} aggiornamenti addon disponibili su {{nodeId}}"
8576
+ },
8569
8577
  "system.addon-update-available.body": "{{list}}",
8570
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8571
8578
  "system.server-update-available.title": {
8572
8579
  "one": "Aggiornamento server disponibile su {{count}} nodo",
8573
8580
  "other": "Aggiornamento server disponibile su {{count}} nodi"
@@ -8582,6 +8589,7 @@ var it_default = {
8582
8589
  "system.addon-updated.body": "{{list}}",
8583
8590
  "system.server-updated.title": "Server aggiornato",
8584
8591
  "system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
8592
+ "system.server-updated.body.toOnly": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}}",
8585
8593
  "system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
8586
8594
  "system.export-completed.title": "Export pronto",
8587
8595
  "system.export-completed.body": "L'export del filmato è pronto",
@@ -10404,15 +10412,15 @@ function incomingFromUpdateAvailable(facts, data) {
10404
10412
  const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
10405
10413
  const packageNames = unique(packages.map((pkg) => pkg.packageName));
10406
10414
  const signature = packages.map((pkg) => `${pkg.nodeId ?? ""}:${pkg.packageName}@${pkg.latestVersion}`).toSorted().join(",");
10407
- const list = packages.map(formatAvailableLine).join("\n");
10408
- const nodes = nodeIds.join(", ");
10409
10415
  const head = packages[0];
10410
10416
  const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
10417
+ const list = packages.map((pkg) => formatAvailableLine(pkg, oneNode !== void 0)).join("\n");
10418
+ const nodes = nodeIds.join(", ");
10411
10419
  return systemSubject({
10412
10420
  facts,
10413
10421
  kind,
10414
10422
  subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
10415
- titleKey: `system.${kind}.title`,
10423
+ titleKey: addon && oneNode !== void 0 ? `system.${kind}.title.onNode` : `system.${kind}.title`,
10416
10424
  bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
10417
10425
  textParams: {
10418
10426
  list,
@@ -10471,12 +10479,12 @@ function incomingFromAddonUpdates(facts, updates) {
10471
10479
  function incomingFromServerUpdated(facts, data) {
10472
10480
  const kind = stringField(data, "kind");
10473
10481
  const requestedBy = stringField(data, "requestedBy");
10474
- if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
10475
10482
  const packageName = stringField(data, "packageName") ?? "@camstack/server";
10476
10483
  const fromVersion = stringField(data, "fromVersion");
10477
10484
  const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
10485
+ if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:") && toVersion !== void 0)) return null;
10478
10486
  const nodeId = stringField(data, "nodeId") ?? "hub";
10479
- const bodyKey = fromVersion !== void 0 && toVersion !== void 0 ? "system.server-updated.body" : "system.server-updated.body.plain";
10487
+ const bodyKey = toVersion === void 0 ? "system.server-updated.body.plain" : fromVersion === void 0 ? "system.server-updated.body.toOnly" : "system.server-updated.body";
10480
10488
  return systemSubject({
10481
10489
  facts,
10482
10490
  kind: "server-updated",
@@ -10557,9 +10565,15 @@ function updatePackages(data) {
10557
10565
  ...nodeId !== void 0 ? { nodeId } : {}
10558
10566
  }];
10559
10567
  }
10560
- function formatAvailableLine(pkg) {
10561
- const line = `${pkg.packageName} ${pkg.latestVersion} (current ${pkg.currentVersion})`;
10562
- return pkg.nodeId !== void 0 ? `${line} on ${pkg.nodeId}` : line;
10568
+ /**
10569
+ * One line of an availability list — the SAME shape the completed-update list
10570
+ * uses (`incomingFromAddonUpdates`), and deliberately word-free: the list is a
10571
+ * frozen param, so any English in it ("current") is delivered verbatim to an
10572
+ * Italian household. An arrow is not a language.
10573
+ */
10574
+ function formatAvailableLine(pkg, nodeNamedInTitle) {
10575
+ const line = `${pkg.packageName} ${pkg.currentVersion} → ${pkg.latestVersion}`;
10576
+ return pkg.nodeId !== void 0 && !nodeNamedInTitle ? `${line} (${pkg.nodeId})` : line;
10563
10577
  }
10564
10578
  function versionFromRequestedBy(requestedBy) {
10565
10579
  if (requestedBy === void 0) return void 0;
@@ -28789,19 +28803,29 @@ var BEST_FRAME_POLICY = {
28789
28803
  sizeAware: true
28790
28804
  };
28791
28805
  /**
28792
- * Best FACE (face-recognizer hold buffer) — confidence-only, no hysteresis, no
28793
- * rate limit. WHY: the face path must keep the TRUE highest-confidence face —
28794
- * accepting a new best only swaps an already-decoded crop buffer in memory
28795
- * (cheap; no capture is triggered), and a track may live well under the
28796
- * best-frame rate-limit window, so a gap/hysteresis would silently discard the
28797
- * real peak for nothing. This was the face path's de-facto behavior all along
28798
- * (it constructed the tracker with defaults); S9 declares it.
28806
+ * Best FACE (face-recognizer hold buffer) — SIZE-AWARE, no hysteresis, no rate
28807
+ * limit.
28808
+ *
28809
+ * No hysteresis / no gap, unchanged: accepting a new best only swaps an
28810
+ * already-decoded crop buffer in memory (cheap; no capture is triggered), and a
28811
+ * track may live well under the best-frame rate-limit window, so a gap would
28812
+ * silently discard the real peak for nothing.
28813
+ *
28814
+ * `sizeAware` since 2026-08-19. It used to be confidence-ONLY, and that was the
28815
+ * wrong ranking: SCRFD saturates its confidence on surveillance footage, so a
28816
+ * 12 px face and a 40 px face both answer ~0.9 and the track simply kept
28817
+ * whichever arrived first. The size that matters is the face's DETECTION-FRAME
28818
+ * short side, because that bounds the landmark precision the alignment warp
28819
+ * consumes — `face-size-score.ts` carries the measurement that set the
28820
+ * reference. Because `minGapMs` is 0 here, enabling this does NOT switch on the
28821
+ * decisive-improvement rate-limit bypass (there is no rate limit to bypass); it
28822
+ * only adds the size factor to the ranking.
28799
28823
  */
28800
28824
  var BEST_FACE_POLICY = {
28801
28825
  hysteresis: 0,
28802
28826
  minGapMs: 0,
28803
28827
  edgeAware: false,
28804
- sizeAware: false
28828
+ sizeAware: true
28805
28829
  };
28806
28830
  /**
28807
28831
  * Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
@@ -32188,7 +32212,11 @@ var AudioDetectionSettingsSchema = object({
32188
32212
  * thresholds, is what bounds the worst case — a mis-tuned threshold can
32189
32213
  * only ever cost the operator this many markers a day. */
32190
32214
  audioMarkerMaxPerDay: number().int().positive().default(12),
32191
- /** Materialize one event + one synthetic track per classified episode. */
32215
+ /** Materialize one event + one synthetic track per classified episode.
32216
+ * OFF switches off the GROUPING only — classified audio still persists an
32217
+ * event (class change, then one per 5 s heartbeat) and still reaches the
32218
+ * Notification Center; it just opens no synthetic track. A knob that names
32219
+ * the grouping must never silence the path (D62). */
32192
32220
  audioEpisodeEnabled: boolean().default(true),
32193
32221
  /** Class must be heard continuously this long before the episode opens. */
32194
32222
  audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
@@ -34408,11 +34436,44 @@ async function resolveDefaultEventMedia(deps) {
34408
34436
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
34409
34437
  }
34410
34438
  //#endregion
34439
+ //#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
34440
+ /**
34441
+ * `null` = accept. A verdict = refuse, carrying both numbers so the caller can
34442
+ * name them in a log.
34443
+ *
34444
+ * Accepts unconditionally when the bound is 0 (disabled) or when the magnitude
34445
+ * is absent / non-finite — an older runner that does not report it must keep
34446
+ * working exactly as before, never be silently gated to nothing. Pure.
34447
+ */
34448
+ function embeddingMagnitudeVerdict(magnitude, bound) {
34449
+ if (!(bound > 0)) return null;
34450
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return null;
34451
+ if (magnitude <= bound) return null;
34452
+ return {
34453
+ magnitude,
34454
+ bound
34455
+ };
34456
+ }
34457
+ /**
34458
+ * Map a detection-frame face short side to the 0..1 `sizeScore` the
34459
+ * {@link ./best-detection-tracker.BestDetectionTracker} multiplies into its
34460
+ * effective score. Linear up to {@link FACE_SIZE_SCORE_REFERENCE_PX}, saturated
34461
+ * above it, 0 for a missing/degenerate measurement (so an unmeasurable face can
34462
+ * never outrank a measured one). Pure.
34463
+ */
34464
+ function faceSizeScore(detectionFrameShortSidePx) {
34465
+ if (!Number.isFinite(detectionFrameShortSidePx) || detectionFrameShortSidePx <= 0) return 0;
34466
+ if (detectionFrameShortSidePx >= 32) return 1;
34467
+ return detectionFrameShortSidePx / 32;
34468
+ }
34469
+ //#endregion
34411
34470
  //#region src/pipeline-analytics/face-recognizer.ts
34412
34471
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
34413
34472
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
34414
34473
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
34415
34474
  var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
34475
+ /** At most one 'embedding magnitude' sample line per device per this interval. */
34476
+ var FACE_MAGNITUDE_LOG_THROTTLE_MS = 6e4;
34416
34477
  /**
34417
34478
  * arcface model id stamped on a detail-plane face candidate when the gallery is
34418
34479
  * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
@@ -34427,6 +34488,8 @@ var FaceRecognizer = class {
34427
34488
  names = /* @__PURE__ */ new Map();
34428
34489
  aggregates = /* @__PURE__ */ new Map();
34429
34490
  bestFace = /* @__PURE__ */ new Map();
34491
+ /** Per-device throttle clock for the magnitude sample line. */
34492
+ lastMagnitudeLogAt = /* @__PURE__ */ new Map();
34430
34493
  /**
34431
34494
  * Peak identity-MATCH cosine per track for the currently-assigned identity
34432
34495
  * (NOT the detection confidence held in {@link bestFace}). Reset on an
@@ -34476,6 +34539,21 @@ var FaceRecognizer = class {
34476
34539
  * input source moves from the per-frame plane to this per-track call.
34477
34540
  */
34478
34541
  async ingestFaceDetail(input) {
34542
+ const verdict = embeddingMagnitudeVerdict(input.embeddingMagnitude, input.settings.maxEmbeddingMagnitude);
34543
+ if (verdict !== null) {
34544
+ this.deps.logger.info("face: dropping detail — raw embedding magnitude over bound", {
34545
+ tags: { deviceId: input.deviceId },
34546
+ meta: {
34547
+ trackId: input.trackId,
34548
+ magnitude: Number(verdict.magnitude.toFixed(3)),
34549
+ bound: verdict.bound,
34550
+ score: input.score,
34551
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: input.nativeFaceShortSidePx } : {}
34552
+ }
34553
+ });
34554
+ return;
34555
+ }
34556
+ this.logEmbeddingMagnitude(input);
34479
34557
  const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
34480
34558
  const candidate = {
34481
34559
  trackId: input.trackId,
@@ -34502,6 +34580,30 @@ var FaceRecognizer = class {
34502
34580
  ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
34503
34581
  });
34504
34582
  }
34583
+ /**
34584
+ * At most one magnitude line per device per
34585
+ * {@link FACE_MAGNITUDE_LOG_THROTTLE_MS}. This is the ONLY way the operator
34586
+ * can see where this cluster's usable and degenerate face populations sit, so
34587
+ * it must exist before `maxEmbeddingMagnitude` can honestly be turned on.
34588
+ */
34589
+ logEmbeddingMagnitude(input) {
34590
+ const magnitude = input.embeddingMagnitude;
34591
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return;
34592
+ const now = Date.now();
34593
+ if (now - (this.lastMagnitudeLogAt.get(input.deviceId) ?? 0) < FACE_MAGNITUDE_LOG_THROTTLE_MS) return;
34594
+ this.lastMagnitudeLogAt.set(input.deviceId, now);
34595
+ this.deps.logger.info("face: embedding magnitude", {
34596
+ tags: { deviceId: input.deviceId },
34597
+ meta: {
34598
+ trackId: input.trackId,
34599
+ magnitude: Number(magnitude.toFixed(3)),
34600
+ bound: input.settings.maxEmbeddingMagnitude,
34601
+ score: input.score,
34602
+ detectionFrameShortSidePx: input.faceBbox !== void 0 ? Math.round(Math.min(input.faceBbox.w, input.faceBbox.h)) : 0,
34603
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: Math.round(input.nativeFaceShortSidePx) } : {}
34604
+ }
34605
+ });
34606
+ }
34505
34607
  async processFrame(input) {
34506
34608
  const { settings } = input;
34507
34609
  const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence && (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.minFacePx);
@@ -34563,7 +34665,8 @@ var FaceRecognizer = class {
34563
34665
  }
34564
34666
  const recognizedIdentityId = state.assignedIdentityId ?? void 0;
34565
34667
  const held = this.bestFace.get(c.trackId);
34566
- const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
34668
+ const detectionFrameShortSidePx = c.faceBbox !== void 0 ? Math.min(c.faceBbox.w, c.faceBbox.h) : 0;
34669
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp, void 0, void 0, void 0, faceSizeScore(detectionFrameShortSidePx));
34567
34670
  const needsCrop = held !== void 0 && held.crop === void 0;
34568
34671
  if (isNewBest || needsCrop) {
34569
34672
  const cropBbox = c.faceBbox ?? c.bbox;
@@ -34823,14 +34926,38 @@ var FaceSettingsSchema = object({
34823
34926
  * stripped from the per-device schema).
34824
34927
  */
34825
34928
  enabled: boolean().default(true),
34826
- /** Cosine similarity (on L2-normalized arcface vectors) required to match.
34827
- * Raised 0.55→0.62 (2026-07-23 face-quality batch): 0.55 was implausibly
34828
- * loose against thin/low-quality galleries and produced ~25-40% visually
34829
- * wrong auto-assignments in the 12h audit. */
34830
- similarityThreshold: number().min(0).max(1).default(.62),
34831
- /** Reject ambiguous matches: require best secondBest margin. Raised
34832
- * 0.10→0.15 (2026-07-23 face-quality batch) to widen the confusion gap. */
34833
- margin: number().min(0).max(1).default(.15),
34929
+ /**
34930
+ * Cosine similarity (on L2-normalized arcface vectors) required to match.
34931
+ *
34932
+ * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
34933
+ * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
34934
+ * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
34935
+ * left almost nothing recognisable. That retune lived only as a RUNTIME
34936
+ * override until 2026-08-19; it is the schema default now, because a default
34937
+ * nobody runs is a decision nobody made.
34938
+ *
34939
+ * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
34940
+ * quality, where most of what reaches ArcFace is not a correctly aligned
34941
+ * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
34942
+ * aligned, 18% marginal, 62% misaligned or containing no detectable face).
34943
+ * On correctly aligned crops the SAME models separate at a far lower
34944
+ * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
34945
+ * 0.175, best accuracy 98.3% at threshold 0.28.
34946
+ *
34947
+ * Recalibration plan, in this order:
34948
+ * 1. land the landmark-precision gate (the alignment fix),
34949
+ * 2. re-enrol the gallery from crops produced AFTER it,
34950
+ * 3. re-measure genuine/impostor over a week of post-fix traffic,
34951
+ * 4. expect to lower this toward ~0.30, and `margin` with it.
34952
+ * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
34953
+ * scores ~0.9 against everything, so the degenerate population sits ABOVE
34954
+ * any threshold you could pick — the knob cannot reach it.
34955
+ */
34956
+ similarityThreshold: number().min(0).max(1).default(.55),
34957
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
34958
+ * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
34959
+ * same story and the same recalibration plan as {@link similarityThreshold}. */
34960
+ margin: number().min(0).max(1).default(.12),
34834
34961
  /** Minimum face-detection confidence for a face to be considered. */
34835
34962
  minFaceConfidence: number().min(0).max(1).default(.5),
34836
34963
  /**
@@ -34874,6 +35001,33 @@ var FaceSettingsSchema = object({
34874
35001
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
34875
35002
  * such a value falls back to the default). */
34876
35003
  confirmFrames: number().int().min(1).default(3),
35004
+ /**
35005
+ * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35006
+ * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35007
+ * the gate.
35008
+ *
35009
+ * This exists because a degenerate crop does not score LOW, it scores HIGH:
35010
+ * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35011
+ * faces exceeded cosine 0.90 and the worst 40% of the face population had
35012
+ * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35013
+ * similarity threshold can reach that — it sits above every threshold — so the
35014
+ * only defence is to refuse the embedding before it is stored or compared.
35015
+ *
35016
+ * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
35017
+ * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
35018
+ * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
35019
+ * 5.25) regardless of size, grey level or blur, while information-free crops
35020
+ * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
35021
+ * 93.2% of degenerate ones.
35022
+ *
35023
+ * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
35024
+ * magnitude has never been observable here — `result-assembler` discarded it
35025
+ * one line after computing it. The field and its logging ship first; set a
35026
+ * real bound once a week of live `face: embedding magnitude` lines says where
35027
+ * this cluster's populations actually sit. Enabling a gate against a number
35028
+ * nobody has measured in production is how recognition silently stops.
35029
+ */
35030
+ maxEmbeddingMagnitude: number().min(0).default(0),
34877
35031
  /** Max buffered faces kept per device. */
34878
35032
  bufferMaxPerDevice: number().int().min(0).default(50)
34879
35033
  });
@@ -34892,6 +35046,7 @@ function resolveFaceSettings(raw) {
34892
35046
  recognitionMinFacePx: pick("recognitionMinFacePx"),
34893
35047
  suggestionMinCosine: pick("suggestionMinCosine"),
34894
35048
  minIdentitySamples: pick("minIdentitySamples"),
35049
+ maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
34895
35050
  confirmFrames: pick("confirmFrames"),
34896
35051
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
34897
35052
  };
@@ -39363,6 +39518,147 @@ async function applyTrackFlags(deps, input) {
39363
39518
  };
39364
39519
  }
39365
39520
  //#endregion
39521
+ //#region src/pipeline-analytics/pipeline/plate-vote.ts
39522
+ /**
39523
+ * Per-character VOTE over a track's plate reads.
39524
+ *
39525
+ * A track produces up to `plate-ocr`'s `cadence.maxPerTrack` reads of the SAME
39526
+ * physical plate, from different frames. The recognizer used to keep the single
39527
+ * read with the highest OCR score (`holdBest`, argmax) — and OCR score does not
39528
+ * correlate with correctness. In the 2026-08 audit the top-scoring read of a
39529
+ * track was routinely the wrong one while two lower-scoring reads agreed on the
39530
+ * right glyph: score ranks how confident the recognizer was about the pixels it
39531
+ * saw, not how much of the plate those pixels were.
39532
+ *
39533
+ * So the reads vote. Each read is normalized (upper, alnum only), aligned to a
39534
+ * reference read, and contributes its OCR score to whichever glyph it saw at
39535
+ * each reference position; the winner per position is the output. Two reads
39536
+ * that agree beat one confident read that disagrees, which is the whole point.
39537
+ *
39538
+ * ALIGNMENT — reads differ in length (4–9 glyphs live: a dropped leading
39539
+ * district letter, a hallucinated trailing glyph). A positional vote on raw
39540
+ * indices would let one missing character shift every later column and destroy
39541
+ * the agreement it was supposed to measure. Each read is therefore aligned to
39542
+ * the reference by Levenshtein traceback: a match or substitution votes at the
39543
+ * reference position it aligned to, an insertion votes nowhere, and a deletion
39544
+ * simply abstains at that position.
39545
+ *
39546
+ * REFERENCE — the length carrying the most SCORE MASS wins (a hallucinated
39547
+ * 9-glyph read does not get to set the output length just by being longest);
39548
+ * ties inside that length go to the highest-scoring read. With ≤5 reads this is
39549
+ * the honest cheap heuristic: there is no third source of truth to consult.
39550
+ */
39551
+ /**
39552
+ * Align `candidate` to `reference` by Levenshtein traceback. Returns one entry
39553
+ * per REFERENCE position: the candidate glyph that aligned there, or `null`
39554
+ * when the candidate had nothing at that position (a deletion). Candidate
39555
+ * insertions are dropped — they have no reference column to vote in.
39556
+ */
39557
+ function alignToReference(reference, candidate) {
39558
+ const n = reference.length;
39559
+ const m = candidate.length;
39560
+ const dist = [];
39561
+ for (let i = 0; i <= n; i++) {
39562
+ const row = new Array(m + 1).fill(0);
39563
+ row[0] = i;
39564
+ dist.push(row);
39565
+ }
39566
+ const first = dist[0];
39567
+ if (first === void 0) return [];
39568
+ for (let j = 0; j <= m; j++) first[j] = j;
39569
+ for (let i = 1; i <= n; i++) {
39570
+ const prev = dist[i - 1];
39571
+ const cur = dist[i];
39572
+ if (prev === void 0 || cur === void 0) continue;
39573
+ for (let j = 1; j <= m; j++) {
39574
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39575
+ cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
39576
+ }
39577
+ }
39578
+ const aligned = new Array(n).fill(null);
39579
+ let i = n;
39580
+ let j = m;
39581
+ while (i > 0 && j > 0) {
39582
+ const cur = dist[i];
39583
+ const prev = dist[i - 1];
39584
+ if (cur === void 0 || prev === void 0) break;
39585
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39586
+ if (cur[j] === (prev[j - 1] ?? 0) + cost) {
39587
+ aligned[i - 1] = candidate[j - 1] ?? null;
39588
+ i--;
39589
+ j--;
39590
+ } else if (cur[j] === (prev[j] ?? 0) + 1) i--;
39591
+ else j--;
39592
+ }
39593
+ return aligned;
39594
+ }
39595
+ /** The read whose LENGTH carries the most score mass; ties → highest score. */
39596
+ function pickReference(reads) {
39597
+ const massByLength = /* @__PURE__ */ new Map();
39598
+ for (const r of reads) massByLength.set(r.text.length, (massByLength.get(r.text.length) ?? 0) + r.score);
39599
+ let bestLength = 0;
39600
+ let bestMass = -1;
39601
+ for (const [length, mass] of massByLength) if (mass > bestMass || mass === bestMass && length < bestLength) {
39602
+ bestMass = mass;
39603
+ bestLength = length;
39604
+ }
39605
+ let reference = "";
39606
+ let referenceScore = -1;
39607
+ for (const r of reads) if (r.text.length === bestLength && r.score > referenceScore) {
39608
+ referenceScore = r.score;
39609
+ reference = r.text;
39610
+ }
39611
+ return reference;
39612
+ }
39613
+ /**
39614
+ * Confidence-weighted per-character vote over a track's plate reads. Returns
39615
+ * the normalized winning text (`''` when there is nothing to vote on).
39616
+ */
39617
+ function votePlateText(reads) {
39618
+ const normalized = [];
39619
+ for (const r of reads) {
39620
+ const text = normalizePlate(r.text);
39621
+ if (text.length > 0) normalized.push({
39622
+ text,
39623
+ score: r.score
39624
+ });
39625
+ }
39626
+ if (normalized.length === 0) return "";
39627
+ const only = normalized[0];
39628
+ if (normalized.length === 1 && only !== void 0) return only.text;
39629
+ const reference = pickReference(normalized);
39630
+ if (reference.length === 0) return "";
39631
+ const columns = [];
39632
+ for (let i = 0; i < reference.length; i++) columns.push(/* @__PURE__ */ new Map());
39633
+ for (const r of normalized) {
39634
+ const aligned = r.text === reference ? [...r.text] : alignToReference(reference, r.text);
39635
+ for (let i = 0; i < reference.length; i++) {
39636
+ const glyph = aligned[i];
39637
+ if (glyph === null || glyph === void 0) continue;
39638
+ const column = columns[i];
39639
+ if (column === void 0) continue;
39640
+ column.set(glyph, (column.get(glyph) ?? 0) + r.score);
39641
+ }
39642
+ }
39643
+ let out = "";
39644
+ for (let i = 0; i < reference.length; i++) {
39645
+ const column = columns[i];
39646
+ const referenceGlyph = reference[i] ?? "";
39647
+ if (column === void 0 || column.size === 0) {
39648
+ out += referenceGlyph;
39649
+ continue;
39650
+ }
39651
+ let bestGlyph = referenceGlyph;
39652
+ let bestMass = column.get(referenceGlyph) ?? -1;
39653
+ for (const [glyph, mass] of column) if (mass > bestMass) {
39654
+ bestMass = mass;
39655
+ bestGlyph = glyph;
39656
+ }
39657
+ out += bestGlyph;
39658
+ }
39659
+ return out;
39660
+ }
39661
+ //#endregion
39366
39662
  //#region src/pipeline-analytics/pipeline/plate-matcher.ts
39367
39663
  /**
39368
39664
  * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
@@ -39411,11 +39707,11 @@ function matchPlateText(probe, gallery, opts) {
39411
39707
  //#endregion
39412
39708
  //#region src/pipeline-analytics/plate-recognizer.ts
39413
39709
  /**
39414
- * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
39415
- * for the plate gallery. Unlike faces there is NO gallery matching: the OCR text
39416
- * IS the label and already rides on the event via the detection pipeline. This
39417
- * only collects per-track reads (+ a tight plate crop) so the gallery can group,
39418
- * review and search them. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39710
+ * PlateRecognizer — collects EVERY license-plate OCR read a vehicle track
39711
+ * produces and stores their per-character VOTE (`plate-vote.ts`) at close, plus
39712
+ * the max-score read's crop for the gallery tile. Unlike faces there is NO
39713
+ * gallery matching: the OCR text IS the label and already rides on the event via
39714
+ * the detection pipeline. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39419
39715
  */
39420
39716
  var PlateRecognizer = class {
39421
39717
  deps;
@@ -39478,7 +39774,7 @@ var PlateRecognizer = class {
39478
39774
  const minConfidence = input.minConfidence ?? 0;
39479
39775
  const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
39480
39776
  if (candidates.length === 0) return;
39481
- for (const c of candidates) await this.holdBest({
39777
+ for (const c of candidates) await this.observeRead({
39482
39778
  deviceId: input.deviceId,
39483
39779
  trackId: c.trackId,
39484
39780
  text: c.plateText,
@@ -39501,15 +39797,50 @@ var PlateRecognizer = class {
39501
39797
  * gallery stays empty (0 plateCrop) — the observed live gap.
39502
39798
  */
39503
39799
  async observePlateRead(input) {
39504
- if (!isPlausiblePlateRead(input.text, input.score)) return;
39505
- if (input.score < (input.minConfidence ?? 0)) return;
39506
- await this.holdBest(input);
39800
+ const minConfidence = input.minConfidence ?? 0;
39801
+ const reason = !isPlausiblePlateRead(input.text, input.score) ? "implausible" : input.score < minConfidence ? "below-min-confidence" : null;
39802
+ if (reason !== null) {
39803
+ this.deps.logger.info("plate: read discarded", {
39804
+ tags: {
39805
+ deviceId: input.deviceId,
39806
+ trackId: input.trackId
39807
+ },
39808
+ meta: {
39809
+ text: input.text,
39810
+ score: input.score,
39811
+ minConfidence,
39812
+ reason
39813
+ }
39814
+ });
39815
+ return;
39816
+ }
39817
+ await this.observeRead(input);
39507
39818
  }
39508
- /** Hold the highest-scoring plate read per track, capturing a tight crop the
39509
- * first time a new best is seen (shared by the per-frame + detail-plane paths). */
39510
- async holdBest(input) {
39819
+ /**
39820
+ * Record one plate read for a track (shared by the per-frame + detail-plane
39821
+ * paths).
39822
+ *
39823
+ * EVERY read is kept for the close-time vote — a track's reads disagree, and
39824
+ * the disagreement is the signal. The crop + geometry still track the
39825
+ * MAX-SCORE read: the vote decides the text, but the picture the operator
39826
+ * reviews should be the sharpest frame the track produced.
39827
+ *
39828
+ * The read list is capped at {@link MAX_VOTING_READS} in ARRIVAL order, not
39829
+ * by score: evicting the lowest-scoring read would re-introduce the very
39830
+ * ranking this vote exists to stop trusting.
39831
+ */
39832
+ async observeRead(input) {
39511
39833
  const held = this.bestPlate.get(input.trackId);
39512
- if (held !== void 0 && input.score <= held.score) return;
39834
+ const vote = {
39835
+ text: input.text,
39836
+ score: input.score
39837
+ };
39838
+ if (held !== void 0 && input.score <= held.score) {
39839
+ if (held.reads.length < 5) held.reads.push(vote);
39840
+ return;
39841
+ }
39842
+ const reads = held?.reads ?? [];
39843
+ if (reads.length < 5) reads.push(vote);
39513
39844
  let crop;
39514
39845
  if (input.frameHandle !== void 0) try {
39515
39846
  crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
@@ -39525,6 +39856,7 @@ var PlateRecognizer = class {
39525
39856
  this.bestPlate.set(input.trackId, {
39526
39857
  text: input.text,
39527
39858
  score: input.score,
39859
+ reads,
39528
39860
  bbox: input.bbox,
39529
39861
  timestamp: input.timestamp,
39530
39862
  frameWidth: input.frameWidth,
@@ -39601,14 +39933,16 @@ var PlateRecognizer = class {
39601
39933
  }
39602
39934
  });
39603
39935
  }
39604
- const match = this.matchVehicle(held.text, held.score);
39936
+ const voted = votePlateText(held.reads);
39937
+ const text = voted.length > 0 && isPlausiblePlateRead(voted, held.score) ? voted : normalizePlate(held.text);
39938
+ const match = this.matchVehicle(text, held.score);
39605
39939
  try {
39606
39940
  await this.deps.plateStore.insert({
39607
39941
  id: plateId,
39608
39942
  deviceId,
39609
39943
  trackId,
39610
39944
  timestamp: held.timestamp,
39611
- text: normalizePlate(held.text),
39945
+ text,
39612
39946
  score: held.score,
39613
39947
  ...mediaKey !== void 0 ? { mediaKey } : {},
39614
39948
  corrected: false,
@@ -39629,7 +39963,10 @@ var PlateRecognizer = class {
39629
39963
  },
39630
39964
  meta: {
39631
39965
  plateId,
39632
- text: normalizePlate(held.text),
39966
+ text,
39967
+ bestRead: normalizePlate(held.text),
39968
+ reads: held.reads.map((r) => `${normalizePlate(r.text)}@${r.score.toFixed(2)}`),
39969
+ votedFromReads: held.reads.length,
39633
39970
  score: held.score,
39634
39971
  hasCrop: mediaKey !== void 0,
39635
39972
  recognizedVehicleId: match?.vehicleId ?? null
@@ -49982,7 +50319,7 @@ function buildGlobalSettingsSchema() {
49982
50319
  type: "boolean",
49983
50320
  key: "audioEpisodeEnabled",
49984
50321
  label: "Create audio episodes",
49985
- description: "When off, classified audio still notifies from other paths but does not open a synthetic track or coalesce classification events.",
50322
+ description: "Off switches off the GROUPING only: classified audio still persists an event and still notifies, but no timeline track is opened and a long sound is no longer collapsed into one episode (one event per class change, then one every 5 s while it lasts).",
49986
50323
  default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
49987
50324
  },
49988
50325
  {
@@ -50108,6 +50445,15 @@ function buildGlobalSettingsSchema() {
50108
50445
  step: 1,
50109
50446
  default: FACE_DEFAULTS.confirmFrames
50110
50447
  },
50448
+ {
50449
+ type: "number",
50450
+ key: "maxEmbeddingMagnitude",
50451
+ label: "Max embedding magnitude (0 = off)",
50452
+ description: "Refuse a face whose RAW ArcFace feature magnitude exceeds this, before it is buffered, matched or enrolled. A degenerate crop does not score LOW against the gallery — it scores HIGH against everything — so no similarity threshold can catch it; the raw magnitude can. On this model a usable face sits around 4.4 whatever its size, grey level or blur, while an information-free crop jumps to 10–16. Leave at 0 until the \"face: embedding magnitude\" log lines show where YOUR cameras sit; a bound guessed from someone else's data silently stops recognising everybody.",
50453
+ min: 0,
50454
+ step: .5,
50455
+ default: FACE_DEFAULTS.maxEmbeddingMagnitude
50456
+ },
50111
50457
  {
50112
50458
  type: "number",
50113
50459
  key: "bufferMaxPerDevice",
@@ -51488,6 +51834,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
51488
51834
  var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
51489
51835
  /** Persist lastSeen on an open synthetic audio track at most this often. */
51490
51836
  var AUDIO_EPISODE_PERSIST_MS = 2e3;
51837
+ /**
51838
+ * Coalescing window for classified audio inserts when episodes are OFF.
51839
+ *
51840
+ * With `audioEpisodeEnabled: false` there is no hysteresis to collapse a sound
51841
+ * into one row, but the classifier still fires at ~30 Hz — so the pre-episode
51842
+ * rule applies again: while the SAME class persists, one row per heartbeat;
51843
+ * a class change inserts immediately. The knob turns off the grouping, never
51844
+ * the persistence (D62).
51845
+ */
51846
+ var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
51491
51847
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
51492
51848
  /**
51493
51849
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
@@ -55343,6 +55699,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55343
55699
  frameHeight: frame.frameHeight,
55344
55700
  score: detail.score,
55345
55701
  embedding: decodeEmbeddingBase64(detail.embedding),
55702
+ ...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
55346
55703
  parentBbox: { ...frame.bbox },
55347
55704
  ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
55348
55705
  ...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
@@ -55810,7 +56167,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55810
56167
  return;
55811
56168
  }
55812
56169
  }
55813
- async openAudioEpisode(input, ref) {
56170
+ /**
56171
+ * The ONE writer for a classified audio observation: the durable row, the
56172
+ * Notification Center hook fired in the same persist moment (D-2), the
56173
+ * track-level `audioLabels` stamp, and the telemetry event.
56174
+ *
56175
+ * Shared by both routes on purpose. `audioEpisodeEnabled` selects WHEN this
56176
+ * runs (once per episode, or once per heartbeat), never WHETHER — a knob
56177
+ * that names the grouping must not be able to silence the classified path
56178
+ * (D62). This body was inlined in `openAudioEpisode` and the knob-off camera
56179
+ * therefore persisted nothing at all.
56180
+ */
56181
+ async persistClassifiedAudio(input) {
55814
56182
  const ev = {
55815
56183
  id: randomUUID(),
55816
56184
  deviceId: input.deviceId,
@@ -55819,18 +56187,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55819
56187
  rms: input.rms,
55820
56188
  dbfs: input.dbfs,
55821
56189
  classification: {
55822
- className: ref.className,
56190
+ className: input.className,
55823
56191
  ...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
55824
56192
  score: input.score
55825
56193
  }
55826
56194
  };
55827
56195
  this.lastAudioInsertByDevice.set(input.deviceId, {
55828
- className: ref.className,
56196
+ className: input.className,
55829
56197
  atMs: input.timestamp
55830
56198
  });
55831
56199
  await this.eventStore.insertAudio(ev);
55832
56200
  this.notificationCenter?.onAudioEventPersisted(ev);
55833
- this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
56201
+ this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
55834
56202
  this.ctx.eventBus.emit({
55835
56203
  id: `pa-${ev.id}`,
55836
56204
  timestamp: new Date(ev.timestamp),
@@ -55847,6 +56215,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55847
56215
  timestamp: ev.timestamp
55848
56216
  }
55849
56217
  });
56218
+ }
56219
+ /**
56220
+ * Classified audio with episodes OFF: persist on class change or heartbeat,
56221
+ * and open NO synthetic track. This is the whole of what the knob switches —
56222
+ * the timeline track and the episode coalescing — and it is what the form
56223
+ * promises.
56224
+ */
56225
+ async persistClassifiedAudioUngrouped(input) {
56226
+ const last = this.lastAudioInsertByDevice.get(input.deviceId);
56227
+ const sameClass = last?.className === input.className;
56228
+ const heartbeatDue = last === void 0 || input.timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
56229
+ if (sameClass && !heartbeatDue) return;
56230
+ await this.persistClassifiedAudio(input);
56231
+ }
56232
+ async openAudioEpisode(input, ref) {
56233
+ await this.persistClassifiedAudio({
56234
+ ...input,
56235
+ className: ref.className
56236
+ });
55850
56237
  if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
55851
56238
  const materializer = this.syntheticTracks;
55852
56239
  if (materializer === null) return;
@@ -55958,19 +56345,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55958
56345
  });
55959
56346
  return;
55960
56347
  }
55961
- await this.driveAudioEpisode({
56348
+ const classified = {
55962
56349
  deviceId,
55963
56350
  timestamp,
55964
56351
  className: route.className,
55965
56352
  score: topClassification.score,
55966
- originalClass: topClassification.debug?.originalClass,
56353
+ ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
55967
56354
  rms: level?.rms ?? 0,
55968
- dbfs: level?.dbfs ?? 0,
56355
+ dbfs: level?.dbfs ?? 0
56356
+ };
56357
+ if (!settings.audioEpisodeEnabled) {
56358
+ await this.persistClassifiedAudioUngrouped(classified);
56359
+ return;
56360
+ }
56361
+ await this.driveAudioEpisode({
56362
+ ...classified,
55969
56363
  knobs: this.audioEpisodeKnobs(settings)
55970
56364
  });
55971
56365
  return;
55972
56366
  }
55973
- await this.driveAudioEpisode({
56367
+ if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
55974
56368
  deviceId,
55975
56369
  timestamp,
55976
56370
  className: null,