@remnic/capture-audio 9.54.2 → 9.54.3

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.
package/dist/cli-bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // openclaw-engram: Local-first memory plugin
3
3
  import {
4
4
  runCapture
5
- } from "./chunk-BRVXKUZY.js";
5
+ } from "./chunk-MFWH245M.js";
6
6
 
7
7
  // src/cli-bin.ts
8
8
  runCapture({ argv: process.argv.slice(2) }).then((code) => {
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ declare const CAPTURE_AUDIO_VERSION = "9.14.0";
12
12
  declare const DEFAULT_HOST = "127.0.0.1";
13
13
  declare const DEFAULT_PORT = 4340;
14
14
  /** Spool schema version, persisted in the `meta` table. */
15
- declare const SPOOL_SCHEMA_VERSION = 1;
15
+ declare const SPOOL_SCHEMA_VERSION = 2;
16
16
 
17
17
  /**
18
18
  * Error taxonomy for @remnic/capture-audio.
@@ -68,6 +68,15 @@ interface DaemonConfig {
68
68
  chunkSeconds: number;
69
69
  captureChannel: "mic" | "system" | "both";
70
70
  conversationGapMinutes: number;
71
+ /**
72
+ * Bounded reorder window, in seconds, for cross-channel arrival skew
73
+ * (issue #2145). Chunks are held until the newest observed chunk end is
74
+ * this far past their own end, then released oldest-first, so a delayed
75
+ * system chunk is grouped with the conversation it belongs to instead of
76
+ * a later mic chunk's. 0 disables buffering: every chunk is released on
77
+ * arrival, which is the pre-#2145 behavior.
78
+ */
79
+ reorderWindowSeconds: number;
71
80
  rawRetentionHours: number;
72
81
  spoolRetentionDays: number;
73
82
  vad: VadConfig;
@@ -158,6 +167,13 @@ type ChunkStatus = "pending" | "transcribed" | "failed" | "deleted";
158
167
  interface SegmentInput {
159
168
  speakerCluster?: string | null;
160
169
  isWearer?: boolean;
170
+ /**
171
+ * Speaker embedding for this segment, persisted as a JSON BLOB (issue
172
+ * #2145). Clustering runs at finalize over the segments that SURVIVE
173
+ * cross-channel dedup, so a pruned loopback duplicate never inflates a
174
+ * cluster's centroid or count.
175
+ */
176
+ embedding?: readonly number[] | null;
161
177
  channel: string;
162
178
  text: string;
163
179
  startUtc: string;
@@ -287,8 +303,82 @@ declare class Spool {
287
303
  * segment_count in sync. Returns the number actually removed.
288
304
  */
289
305
  deleteSegments(ids: readonly string[]): number;
306
+ /**
307
+ * Segments of one conversation that still need a speaker, chronological.
308
+ *
309
+ * Only rows with a stored embedding and no cluster yet: clustering runs at
310
+ * finalize over the segments that SURVIVED dedup (issue #2145), and skipping
311
+ * already-assigned rows keeps a repeated finalize from double-counting a
312
+ * centroid.
313
+ */
314
+ conversationSegmentsForDiarization(conversationId: string): Array<{
315
+ id: string;
316
+ channel: string;
317
+ embedding: number[];
318
+ }>;
319
+ /**
320
+ * Commit one conversation's diarization: cluster snapshots and the segment
321
+ * assignments that produced them, in ONE transaction.
322
+ *
323
+ * Splitting the two lets a crash persist an updated `embedding_count` while
324
+ * its segments stay unassigned; the next finalize would select the same rows
325
+ * and count the same embeddings again (issue #2145). Atomicity is what makes
326
+ * the repeated-finalize idempotency claim true.
327
+ */
328
+ commitDiarization(input: {
329
+ clusters: readonly SpeakerInput[];
330
+ assignments: ReadonlyArray<{
331
+ id: string;
332
+ speakerCluster: string;
333
+ isWearer: boolean;
334
+ }>;
335
+ }): number;
290
336
  /** Ids of every still-`capturing` conversation (dedup-before-finalize sweep). */
291
337
  capturingConversationIds(): string[];
338
+ /**
339
+ * Record a bare idempotency marker (no segments).
340
+ *
341
+ * Used to persist facts a later replay cannot re-derive — such as how many
342
+ * segments a chunk's transcript produced, which is the only way to tell a
343
+ * legitimately shorter retranscription from a missing tail (issue #2145).
344
+ */
345
+ markApplied(idempotencyKey: string, conversationId: string): void;
346
+ /**
347
+ * Whether ANY idempotency key for this chunk was applied.
348
+ *
349
+ * Only a SILENT replay needs this — a chunk partially applied by a binary
350
+ * predating the transcript manifest has no manifest to compare, and a
351
+ * zero-segment replay has no per-segment key to look up exactly. Speech
352
+ * chunks use the indexed manifest lookup below, so continuous capture never
353
+ * pays for this scan (issue #2145).
354
+ */
355
+ hasAppliedChunkPrefix(chunkIdPrefix: string): boolean;
356
+ /**
357
+ * Conversations a chunk actually contributed stored segments to.
358
+ *
359
+ * Used to scope a rebuilt replay hold to the prefix that chunk belongs to,
360
+ * rather than to every conversation that happens to be capturing (#2145).
361
+ * Matches the bare chunk id and every per-segment or per-group derivative
362
+ * (`<chunkId>:h<hash>`, and the pre-manifest `<chunkId>:<n>`).
363
+ */
364
+ conversationIdsForChunk(chunkId: string): string[];
365
+ /**
366
+ * Chunks whose transcript manifest is recorded but which never completed.
367
+ *
368
+ * A restart loses the in-memory record of which chunks are still awaiting a
369
+ * replay, so it is re-derived from these two durable markers: the manifest is
370
+ * written before any append, `:done` only after every segment is stored
371
+ * (issue #2145).
372
+ */
373
+ incompleteChunkIds(): string[];
374
+ /**
375
+ * The value stored alongside an idempotency marker, or `undefined`.
376
+ *
377
+ * `markApplied` uses this column to carry a fact a replay cannot re-derive —
378
+ * the chunk's transcript manifest hash — and this is the exact, primary-key
379
+ * lookup that reads it back (issue #2145).
380
+ */
381
+ appliedChunkValue(idempotencyKey: string): string | undefined;
292
382
  /** Whether a chunk with this idempotency key was already durably applied. */
293
383
  isChunkApplied(idempotencyKey: string): boolean;
294
384
  /**
@@ -307,6 +397,12 @@ declare class Spool {
307
397
  startedAtUtc: string;
308
398
  endedAtUtc: string;
309
399
  } | null;
400
+ /** One capturing conversation by id, for resuming a specific prefix. */
401
+ capturingConversationById(id: string): {
402
+ id: string;
403
+ startedAtUtc: string;
404
+ endedAtUtc: string;
405
+ } | null;
310
406
  upsertSpeaker(input: SpeakerInput): void;
311
407
  /** Read every speaker cluster with decoded centroid + examples (diarization restart seed). */
312
408
  readSpeakerClusters(): SpeakerClusterRow[];
@@ -686,6 +782,27 @@ declare class ConversationAssembler {
686
782
  startedAtUtc: string;
687
783
  endedAtUtc: string;
688
784
  }): void;
785
+ /**
786
+ * Drop finalized conversations the caller no longer needs.
787
+ *
788
+ * A long-running daemon would otherwise retain every conversation and every
789
+ * segment forever, which makes the rollback snapshot below O(capture
790
+ * history) and the daemon's per-chunk work quadratic (issue #2145). Only the
791
+ * open conversation can still be mutated, so nothing else needs keeping.
792
+ */
793
+ pruneFinalized(): number;
794
+ /**
795
+ * Deep snapshot for rollback (issue #2145).
796
+ *
797
+ * `add` mutates the open conversation in place. A caller that fails BEFORE
798
+ * anything was persisted must be able to rewind, or the retry feeds earlier
799
+ * timestamps into an advanced assembler and collapses conversations the
800
+ * first attempt had split. A caller that already persisted something must
801
+ * NOT rewind: the durable ids would then diverge from the in-memory ones.
802
+ */
803
+ checkpoint(): AssembledConversation[];
804
+ /** Rewind to a {@link checkpoint}. */
805
+ rewind(snapshot: readonly AssembledConversation[]): void;
689
806
  /** Ordered snapshot; segments are cloned so callers cannot mutate internal state. */
690
807
  conversations(): AssembledConversation[];
691
808
  /**
@@ -733,6 +850,15 @@ declare class SpeakerClusterer {
733
850
  enrollSelf(embedding: Embedding): void;
734
851
  /** Match `embedding` to an existing cluster or create a new `spk_<n>`. */
735
852
  assign(embedding: Embedding): string;
853
+ /**
854
+ * Replace every cluster with `snapshot` (issue #2145).
855
+ *
856
+ * `assign` mutates centroids and counts in place, so a diarization commit
857
+ * that rolls back in SQLite must roll back here too — otherwise the retry
858
+ * counts the same embeddings twice. Deep-copied, so the caller's snapshot
859
+ * cannot alias internal state.
860
+ */
861
+ restore(snapshot: readonly SpeakerCluster[]): void;
736
862
  /** Snapshot for persistence. */
737
863
  clusters(): SpeakerCluster[];
738
864
  }
@@ -927,6 +1053,15 @@ interface ChunkProcessorDeps {
927
1053
  diarizer?: SpeakerClusterer;
928
1054
  /** Cross-channel dedup window in ms; defaults to the dedup module's tolerance. */
929
1055
  dedupWindowMs?: number;
1056
+ /**
1057
+ * Bounded reorder window in ms for cross-channel arrival skew (issue
1058
+ * #2145). A transcribed chunk is HELD until the newest observed chunk end
1059
+ * is this far past its own end, then released oldest-first, so a delayed
1060
+ * system chunk is assembled into the conversation it temporally belongs to
1061
+ * rather than joined to a later mic chunk's. 0 (the default here, so
1062
+ * existing callers are unchanged) releases every chunk on arrival.
1063
+ */
1064
+ reorderWindowMs?: number;
930
1065
  /** Reports a per-chunk failure; the chain keeps running afterwards. */
931
1066
  onError?: (error: Error, event: ChunkEvent) => void;
932
1067
  }
@@ -938,11 +1073,6 @@ interface ChunkProcessor {
938
1073
  /** Drain, then flip open conversations to `final`; returns the count closed. */
939
1074
  finalize(): Promise<number>;
940
1075
  }
941
- /**
942
- * Stable chunk identity derived purely from the WAV path. Because it never
943
- * depends on a freshly-generated conversation id, the same chunk yields the
944
- * same idempotency key across process restarts.
945
- */
946
1076
  declare function chunkStableId(event: ChunkEvent): string;
947
1077
  declare function createChunkProcessor(deps: ChunkProcessorDeps): ChunkProcessor;
948
1078
 
package/dist/index.js CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  whisperModelUrl,
71
71
  wordJaccard,
72
72
  writePidFile
73
- } from "./chunk-BRVXKUZY.js";
73
+ } from "./chunk-MFWH245M.js";
74
74
 
75
75
  // src/vad.ts
76
76
  import { statSync } from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/capture-audio",
3
- "version": "9.54.2",
3
+ "version": "9.54.3",
4
4
  "description": "Desktop audio capture daemon for Remnic — local spool, loopback HTTP API, and replay ingestion for the wearable source `desktop` (à-la-carte)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,13 +29,13 @@
29
29
  "tsup": "^8.0.0",
30
30
  "typescript": "^5.7.0",
31
31
  "tsx": "^4.0.0",
32
- "@remnic/core": "9.54.2"
32
+ "@remnic/core": "9.54.3"
33
33
  },
34
34
  "peerDependencies": {
35
- "@remnic/core": "^9.54.2",
35
+ "@remnic/core": "^9.54.3",
36
36
  "sherpa-onnx-node": "*",
37
- "@remnic/capture-native-darwin-arm64": "^9.54.2",
38
- "@remnic/capture-native-darwin-x64": "^9.54.2"
37
+ "@remnic/capture-native-darwin-arm64": "^9.54.3",
38
+ "@remnic/capture-native-darwin-x64": "^9.54.3"
39
39
  },
40
40
  "peerDependenciesMeta": {
41
41
  "sherpa-onnx-node": {