@remnic/capture-audio 9.24.0
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/chunk-BRVXKUZY.js +3187 -0
- package/dist/chunk-BRVXKUZY.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +15 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/index.d.ts +1090 -0
- package/dist/index.js +405 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { WearableConnectorFactoryOptions, WearableSourceConnector, WearableConversation, WearableConnectorRegistration } from '@remnic/core';
|
|
3
|
+
|
|
4
|
+
/** Package-wide constants for @remnic/capture-audio. */
|
|
5
|
+
/**
|
|
6
|
+
* Reported by GET /v1/health. Kept in sync with package.json by the
|
|
7
|
+
* release tooling; the health endpoint tolerates drift because the
|
|
8
|
+
* connector never gates on an exact match (it reads `ok`).
|
|
9
|
+
*/
|
|
10
|
+
declare const CAPTURE_AUDIO_VERSION = "9.14.0";
|
|
11
|
+
/** Loopback default; capture is local-first (charter). */
|
|
12
|
+
declare const DEFAULT_HOST = "127.0.0.1";
|
|
13
|
+
declare const DEFAULT_PORT = 4340;
|
|
14
|
+
/** Spool schema version, persisted in the `meta` table. */
|
|
15
|
+
declare const SPOOL_SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Error taxonomy for @remnic/capture-audio.
|
|
19
|
+
*
|
|
20
|
+
* Two authored-message classes, mirroring the wearables split
|
|
21
|
+
* (packages/remnic-core/src/wearables/errors.ts): configuration problems
|
|
22
|
+
* and caller-correctable input. Both carry operator-safe messages (never
|
|
23
|
+
* foreign error text, never credentials). The HTTP layer maps
|
|
24
|
+
* CaptureInputError to 400; anything else is a backend fault (500).
|
|
25
|
+
*/
|
|
26
|
+
/** Config load/validation failure — surfaced loudly, never silently defaulted (rule 39). */
|
|
27
|
+
declare class CaptureConfigError extends Error {
|
|
28
|
+
constructor(message: string);
|
|
29
|
+
}
|
|
30
|
+
/** Caller-correctable request/CLI input — maps to HTTP 400. */
|
|
31
|
+
declare class CaptureInputError extends Error {
|
|
32
|
+
constructor(message: string);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Daemon config (`~/.remnic/capture/audio.json`), created by
|
|
37
|
+
* `remnic-capture-audio init`. Strict and loud: an absent field takes the
|
|
38
|
+
* documented default, but a present-but-invalid value throws
|
|
39
|
+
* CaptureConfigError (rule 39 — no silent defaulting). Ports are integers
|
|
40
|
+
* in [1, 65535] (rule 17); booleans coerce boolean-like strings (rule 24).
|
|
41
|
+
*
|
|
42
|
+
* Only `whisper-cpp` is currently accepted for STT. VAD configuration maps
|
|
43
|
+
* directly to the optional Sherpa Silero runtime adapter.
|
|
44
|
+
*/
|
|
45
|
+
interface SttConfig {
|
|
46
|
+
engine: "whisper-cpp";
|
|
47
|
+
modelPath: string | null;
|
|
48
|
+
threads: number | null;
|
|
49
|
+
}
|
|
50
|
+
interface VadConfig {
|
|
51
|
+
modelPath: string | null;
|
|
52
|
+
minSpeechMs: number;
|
|
53
|
+
minSilenceMs: number;
|
|
54
|
+
maxSpeechMs: number;
|
|
55
|
+
threshold: number;
|
|
56
|
+
threads: number;
|
|
57
|
+
}
|
|
58
|
+
interface DiarizationConfig {
|
|
59
|
+
similarityThreshold: number;
|
|
60
|
+
}
|
|
61
|
+
interface DeviceConfig {
|
|
62
|
+
mic: string | null;
|
|
63
|
+
system: string | null;
|
|
64
|
+
}
|
|
65
|
+
interface DaemonConfig {
|
|
66
|
+
host: string;
|
|
67
|
+
port: number;
|
|
68
|
+
chunkSeconds: number;
|
|
69
|
+
captureChannel: "mic" | "system" | "both";
|
|
70
|
+
conversationGapMinutes: number;
|
|
71
|
+
rawRetentionHours: number;
|
|
72
|
+
spoolRetentionDays: number;
|
|
73
|
+
vad: VadConfig;
|
|
74
|
+
diarization: DiarizationConfig;
|
|
75
|
+
stt: SttConfig;
|
|
76
|
+
denyApps: string[];
|
|
77
|
+
devices: DeviceConfig;
|
|
78
|
+
}
|
|
79
|
+
declare function defaultDaemonConfig(): DaemonConfig;
|
|
80
|
+
declare function parseDaemonConfig(raw: unknown): DaemonConfig;
|
|
81
|
+
declare function loadDaemonConfig(configPath: string): DaemonConfig;
|
|
82
|
+
declare function serializeDaemonConfig(cfg: DaemonConfig): string;
|
|
83
|
+
|
|
84
|
+
/** Filesystem layout for the capture working directory. */
|
|
85
|
+
interface CapturePaths {
|
|
86
|
+
baseDir: string;
|
|
87
|
+
configPath: string;
|
|
88
|
+
spoolPath: string;
|
|
89
|
+
tokenPath: string;
|
|
90
|
+
pidPath: string;
|
|
91
|
+
logPath: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Root of the capture working directory. `REMNIC_CAPTURE_DIR` overrides
|
|
95
|
+
* the default `~/.remnic/capture` (tests and multi-instance setups point
|
|
96
|
+
* it at a scratch dir). A leading `~` expands to the home directory.
|
|
97
|
+
*/
|
|
98
|
+
declare function captureBaseDir(env?: NodeJS.ProcessEnv): string;
|
|
99
|
+
declare function capturePaths(baseDir?: string): CapturePaths;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Bearer-token lifecycle. The daemon auto-generates a 256-bit token on
|
|
103
|
+
* first use and stores it 0600; a pre-existing file is re-chmod'd 0600
|
|
104
|
+
* defensively because a world-readable token is a credential leak. The
|
|
105
|
+
* token is REQUIRED on every request when the daemon binds a non-loopback
|
|
106
|
+
* host (see daemon.ts); on loopback it exists but localhost is trusted.
|
|
107
|
+
*/
|
|
108
|
+
declare function generateToken(): string;
|
|
109
|
+
declare function loadOrCreateToken(tokenPath: string): string;
|
|
110
|
+
/** Constant-time compare; unequal lengths short-circuit to false. */
|
|
111
|
+
declare function tokensMatch(expected: string, presented: string): boolean;
|
|
112
|
+
/** Parse `Authorization: Bearer <token>`; returns null when absent/malformed. */
|
|
113
|
+
declare function bearerFromHeader(header: string | string[] | undefined): string | null;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Request-input validation for the HTTP surface. Every failure raises
|
|
117
|
+
* CaptureInputError, which the daemon maps to HTTP 400 — invalid date,
|
|
118
|
+
* timezone, limit, or cursor is rejected loudly, never silently defaulted
|
|
119
|
+
* (rule 39). The keyset cursor is an opaque base64url token over the
|
|
120
|
+
* (started_at_utc, id) tuple the conversations query orders by.
|
|
121
|
+
*/
|
|
122
|
+
/** Validate a YYYY-MM-DD calendar date (rejects e.g. 2026-02-30). */
|
|
123
|
+
declare function parseTranscriptDate(value: string | null | undefined): string;
|
|
124
|
+
/** Validate an IANA timezone by attempting to build a formatter for it. */
|
|
125
|
+
declare function assertValidTimezone(value: string | null | undefined): string;
|
|
126
|
+
/** Absent limit → default; present-but-invalid → 400. */
|
|
127
|
+
declare function parseLimit(value: string | null | undefined): number;
|
|
128
|
+
interface Cursor {
|
|
129
|
+
startedAtUtc: string;
|
|
130
|
+
id: string;
|
|
131
|
+
}
|
|
132
|
+
declare function encodeCursor(startedAtUtc: string, id: string): string;
|
|
133
|
+
/** Absent cursor → null (first page); malformed cursor → 400. */
|
|
134
|
+
declare function decodeCursor(value: string | null | undefined): Cursor | null;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* SQLite spool — the daemon's local buffer of captured conversations.
|
|
138
|
+
*
|
|
139
|
+
* Uses the built-in `node:sqlite` driver (no native dependency), keeping
|
|
140
|
+
* @remnic/capture-audio à-la-carte: installing it pulls zero extra runtime
|
|
141
|
+
* packages. WAL mode + foreign keys are enabled per connection.
|
|
142
|
+
*
|
|
143
|
+
* Schema (names/semantics fixed by issue #1897):
|
|
144
|
+
* chunks(id, channel, device, started_at_utc, ended_at_utc, status, wav_path)
|
|
145
|
+
* segments(id, chunk_id FK, conversation_id FK, speaker_cluster, is_wearer,
|
|
146
|
+
* channel, text, start_utc, end_utc, ordinal)
|
|
147
|
+
* conversations(id, started_at_utc, ended_at_utc, state, segment_count)
|
|
148
|
+
* speaker_clusters(id, label, centroid, example_embeddings, embedding_count, is_self)
|
|
149
|
+
* meta(key, value)
|
|
150
|
+
*
|
|
151
|
+
* The public read API (`queryFinalConversations`) serves ONLY `final`
|
|
152
|
+
* conversations, ordered by a stable keyset (started_at_utc, id) so the
|
|
153
|
+
* connector never ingests half a meeting and pagination is deterministic
|
|
154
|
+
* even when two conversations share a start timestamp.
|
|
155
|
+
*/
|
|
156
|
+
type ConversationState = "capturing" | "final";
|
|
157
|
+
type ChunkStatus = "pending" | "transcribed" | "failed" | "deleted";
|
|
158
|
+
interface SegmentInput {
|
|
159
|
+
speakerCluster?: string | null;
|
|
160
|
+
isWearer?: boolean;
|
|
161
|
+
channel: string;
|
|
162
|
+
text: string;
|
|
163
|
+
startUtc: string;
|
|
164
|
+
endUtc: string;
|
|
165
|
+
}
|
|
166
|
+
interface ConversationInput {
|
|
167
|
+
id?: string;
|
|
168
|
+
startedAtUtc: string;
|
|
169
|
+
endedAtUtc?: string | null;
|
|
170
|
+
state?: ConversationState;
|
|
171
|
+
device?: string | null;
|
|
172
|
+
chunkStatus?: ChunkStatus;
|
|
173
|
+
wavPath?: string | null;
|
|
174
|
+
segments: SegmentInput[];
|
|
175
|
+
}
|
|
176
|
+
interface SpeakerInput {
|
|
177
|
+
id: string;
|
|
178
|
+
label?: string | null;
|
|
179
|
+
isSelf?: boolean;
|
|
180
|
+
embeddingCount?: number;
|
|
181
|
+
/** Speaker embedding centroid; persisted as a JSON BLOB for restart-stable ids. */
|
|
182
|
+
centroid?: readonly number[] | null;
|
|
183
|
+
/** Bounded diverse example embeddings; persisted as a JSON BLOB. */
|
|
184
|
+
examples?: readonly (readonly number[])[] | null;
|
|
185
|
+
}
|
|
186
|
+
interface SpeakerClusterRow {
|
|
187
|
+
id: string;
|
|
188
|
+
label: string | null;
|
|
189
|
+
isSelf: boolean;
|
|
190
|
+
embeddingCount: number;
|
|
191
|
+
centroid: number[];
|
|
192
|
+
examples: number[][];
|
|
193
|
+
}
|
|
194
|
+
interface DaemonSegment {
|
|
195
|
+
textRaw: string;
|
|
196
|
+
speakerKey: string | null;
|
|
197
|
+
isWearer: boolean;
|
|
198
|
+
channel: string;
|
|
199
|
+
startUtc: string;
|
|
200
|
+
endUtc: string;
|
|
201
|
+
}
|
|
202
|
+
interface DaemonConversation {
|
|
203
|
+
id: string;
|
|
204
|
+
startedAtUtc: string;
|
|
205
|
+
endedAtUtc: string | null;
|
|
206
|
+
state: ConversationState;
|
|
207
|
+
segmentCount: number;
|
|
208
|
+
segments: DaemonSegment[];
|
|
209
|
+
}
|
|
210
|
+
interface ConversationPage {
|
|
211
|
+
conversations: DaemonConversation[];
|
|
212
|
+
nextCursor: string | null;
|
|
213
|
+
}
|
|
214
|
+
interface SpeakerRow {
|
|
215
|
+
id: string;
|
|
216
|
+
label: string | null;
|
|
217
|
+
isSelf: boolean;
|
|
218
|
+
embeddingCount: number;
|
|
219
|
+
}
|
|
220
|
+
interface QueryFinalOptions {
|
|
221
|
+
date: string;
|
|
222
|
+
timezone: string;
|
|
223
|
+
cursor?: string | null;
|
|
224
|
+
limit: number;
|
|
225
|
+
}
|
|
226
|
+
interface AssemblyAppendInput {
|
|
227
|
+
/** Durable dedup marker for one application (e.g. a transcribed chunk id). */
|
|
228
|
+
idempotencyKey: string;
|
|
229
|
+
/** Stable `conv_<ulid>` id from the assembler. */
|
|
230
|
+
conversationId: string;
|
|
231
|
+
/** Conversation start; used only when the conversation is first created. */
|
|
232
|
+
startedAtUtc: string;
|
|
233
|
+
/** Defaults to `capturing`; the conversation is finalized by a later call. */
|
|
234
|
+
state?: ConversationState;
|
|
235
|
+
device?: string | null;
|
|
236
|
+
/** Backing chunk row id; defaults to `idempotencyKey`. */
|
|
237
|
+
chunkId?: string;
|
|
238
|
+
/** Backing WAV path recorded on the chunk row; retained for the janitor/audit. */
|
|
239
|
+
wavPath?: string | null;
|
|
240
|
+
segments: SegmentInput[];
|
|
241
|
+
}
|
|
242
|
+
interface AssemblyAppendResult {
|
|
243
|
+
/** False when `idempotencyKey` was already applied (a replay no-op). */
|
|
244
|
+
applied: boolean;
|
|
245
|
+
conversationId: string;
|
|
246
|
+
/** Total segments in the conversation after this call. */
|
|
247
|
+
segmentCount: number;
|
|
248
|
+
}
|
|
249
|
+
declare class Spool {
|
|
250
|
+
#private;
|
|
251
|
+
constructor(location: string);
|
|
252
|
+
close(): void;
|
|
253
|
+
meta(key: string): string | null;
|
|
254
|
+
setMeta(key: string, value: string): void;
|
|
255
|
+
/**
|
|
256
|
+
* Insert (or replace) a whole conversation with its segments and a
|
|
257
|
+
* backing chunk row, atomically. Idempotent by conversation id:
|
|
258
|
+
* re-ingesting the same id deletes the prior rows first, so a repeated
|
|
259
|
+
* replay is a content no-op (kill-9 restart safety, acceptance criteria).
|
|
260
|
+
*/
|
|
261
|
+
insertConversation(input: ConversationInput): string;
|
|
262
|
+
/** Flip every still-open conversation to `final` (daemon stop / gap timeout). */
|
|
263
|
+
finalizeOpenConversations(): number;
|
|
264
|
+
/**
|
|
265
|
+
* Durably append one transcribed chunk's segments to a conversation,
|
|
266
|
+
* idempotent on `idempotencyKey` (a replay/restart of the same chunk is a
|
|
267
|
+
* no-op). Creates the conversation as `capturing` on first append; a later
|
|
268
|
+
* `finalizeConversation`/`finalizeOpenConversations` flips it to `final`.
|
|
269
|
+
*/
|
|
270
|
+
appendAssembledSegments(input: AssemblyAppendInput): AssemblyAppendResult;
|
|
271
|
+
/** Flip one conversation to `final`; returns true when it was capturing. */
|
|
272
|
+
finalizeConversation(id: string): boolean;
|
|
273
|
+
/**
|
|
274
|
+
* A conversation's segments in the shape cross-channel dedup needs (segment
|
|
275
|
+
* id + DedupSegment fields), chronological. Used to prune loopback duplicates
|
|
276
|
+
* at finalization, which is order-independent (all segments are present).
|
|
277
|
+
*/
|
|
278
|
+
conversationSegmentsForDedup(conversationId: string): Array<{
|
|
279
|
+
id: string;
|
|
280
|
+
channel: string;
|
|
281
|
+
text: string;
|
|
282
|
+
startUtc: string;
|
|
283
|
+
endUtc: string;
|
|
284
|
+
}>;
|
|
285
|
+
/**
|
|
286
|
+
* Delete specific segments (dedup prune), keeping each owning conversation's
|
|
287
|
+
* segment_count in sync. Returns the number actually removed.
|
|
288
|
+
*/
|
|
289
|
+
deleteSegments(ids: readonly string[]): number;
|
|
290
|
+
/** Ids of every still-`capturing` conversation (dedup-before-finalize sweep). */
|
|
291
|
+
capturingConversationIds(): string[];
|
|
292
|
+
/** Whether a chunk with this idempotency key was already durably applied. */
|
|
293
|
+
isChunkApplied(idempotencyKey: string): boolean;
|
|
294
|
+
/**
|
|
295
|
+
* Record that a whole chunk finished (every group appended) via a `<id>:done`
|
|
296
|
+
* marker, so a later full replay can skip transcription + diarization. A crash
|
|
297
|
+
* before this leaves no marker, so the missing groups re-append on replay.
|
|
298
|
+
*/
|
|
299
|
+
markChunkComplete(chunkId: string, conversationId: string): void;
|
|
300
|
+
/**
|
|
301
|
+
* The newest still-`capturing` conversation, so a chunk arriving after a
|
|
302
|
+
* process restart continues it (subject to the assembler's gap rule) instead
|
|
303
|
+
* of splitting off a new one. Null when none is open.
|
|
304
|
+
*/
|
|
305
|
+
latestCapturingConversation(): {
|
|
306
|
+
id: string;
|
|
307
|
+
startedAtUtc: string;
|
|
308
|
+
endedAtUtc: string;
|
|
309
|
+
} | null;
|
|
310
|
+
upsertSpeaker(input: SpeakerInput): void;
|
|
311
|
+
/** Read every speaker cluster with decoded centroid + examples (diarization restart seed). */
|
|
312
|
+
readSpeakerClusters(): SpeakerClusterRow[];
|
|
313
|
+
listSpeakers(): SpeakerRow[];
|
|
314
|
+
pendingChunkCount(): number;
|
|
315
|
+
stats(): {
|
|
316
|
+
conversations: number;
|
|
317
|
+
segments: number;
|
|
318
|
+
chunks: number;
|
|
319
|
+
};
|
|
320
|
+
getConversation(id: string): DaemonConversation | null;
|
|
321
|
+
/**
|
|
322
|
+
* Final conversations whose local day (per `timezone`) equals `date`,
|
|
323
|
+
* paged by the stable (started_at_utc, id) keyset. Fetches all final
|
|
324
|
+
* rows after the cursor (the spool is a bounded buffer, not an archive),
|
|
325
|
+
* filters to the requested local day, then pages — so the id tiebreak
|
|
326
|
+
* keeps pagination correct across duplicate start timestamps.
|
|
327
|
+
*/
|
|
328
|
+
queryFinalConversations(opts: QueryFinalOptions): ConversationPage;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `--replay <dir>` ingestion. Feeds synthetic fixture conversations into
|
|
333
|
+
* the spool so the entire read path (spool + HTTP API) is testable in CI
|
|
334
|
+
* without capture hardware or STT. Fixtures are synthetic by policy — no
|
|
335
|
+
* real audio or conversation data lives in the repo, and none is required
|
|
336
|
+
* for tests.
|
|
337
|
+
*
|
|
338
|
+
* Each `*.json` fixture is either a single conversation object or an array
|
|
339
|
+
* of them. Every field is validated loudly: an absent optional field takes
|
|
340
|
+
* its default, but a present-but-wrong-typed/invalid field throws
|
|
341
|
+
* CaptureConfigError naming the file and path (no silent coercion). A
|
|
342
|
+
* conversation is fully parsed BEFORE its speakers are upserted, so a
|
|
343
|
+
* malformed conversation never persists speaker rows.
|
|
344
|
+
*
|
|
345
|
+
* {
|
|
346
|
+
* "id": "conv_demo1", // optional; generated if absent
|
|
347
|
+
* "startedAtUtc": "2026-07-20T15:00:00.000Z",
|
|
348
|
+
* "endedAtUtc": "2026-07-20T15:05:00.000Z", // optional
|
|
349
|
+
* "state": "final", // optional; "final" | "capturing"
|
|
350
|
+
* "device": "MacBook mic", // optional
|
|
351
|
+
* "speakers": [ { "id": "spk_1", "label": "Alice", "isSelf": false } ],
|
|
352
|
+
* "segments": [
|
|
353
|
+
* { "speakerCluster": "spk_1", "isWearer": false, "channel": "mic",
|
|
354
|
+
* "text": "hello there", "startUtc": "...", "endUtc": "..." }
|
|
355
|
+
* ]
|
|
356
|
+
* }
|
|
357
|
+
*
|
|
358
|
+
* Ingestion is idempotent by conversation id (see Spool.insertConversation),
|
|
359
|
+
* so re-running a replay is a content no-op.
|
|
360
|
+
*/
|
|
361
|
+
|
|
362
|
+
interface ReplayResult {
|
|
363
|
+
files: number;
|
|
364
|
+
conversationsIngested: number;
|
|
365
|
+
segmentsIngested: number;
|
|
366
|
+
ids: string[];
|
|
367
|
+
/** True when a cooperative cancel (AbortSignal) stopped ingestion early. */
|
|
368
|
+
aborted: boolean;
|
|
369
|
+
}
|
|
370
|
+
/** Commit size between event-loop yields in the responsive ingester. */
|
|
371
|
+
declare const REPLAY_COMMIT_BATCH = 25;
|
|
372
|
+
/** Synchronous ingest: validate the whole directory, then commit it all. */
|
|
373
|
+
declare function ingestReplayDir(spool: Spool, dir: string): ReplayResult;
|
|
374
|
+
/**
|
|
375
|
+
* Responsive ingest: everything is validated up front (atomic — a later
|
|
376
|
+
* invalid record commits nothing), then committed in bounded batches with an
|
|
377
|
+
* event-loop yield between them so a co-hosted HTTP server stays responsive
|
|
378
|
+
* during a large replay.
|
|
379
|
+
*/
|
|
380
|
+
declare function ingestReplayDirResponsive(spool: Spool, dir: string, options?: {
|
|
381
|
+
signal?: AbortSignal;
|
|
382
|
+
}): Promise<ReplayResult>;
|
|
383
|
+
|
|
384
|
+
interface TranscribedSegment {
|
|
385
|
+
text: string;
|
|
386
|
+
startUtc: string;
|
|
387
|
+
endUtc: string;
|
|
388
|
+
}
|
|
389
|
+
interface WhisperRunResult {
|
|
390
|
+
code: number;
|
|
391
|
+
stdout: string;
|
|
392
|
+
stderr: string;
|
|
393
|
+
}
|
|
394
|
+
interface WhisperTranscriptionInput {
|
|
395
|
+
wavPath: string;
|
|
396
|
+
modelPath: string;
|
|
397
|
+
chunkStartedAtUtc: string;
|
|
398
|
+
threads?: number | null;
|
|
399
|
+
run: (command: string, args: string[]) => Promise<WhisperRunResult>;
|
|
400
|
+
}
|
|
401
|
+
declare function parseWhisperJson(output: string, chunkStartedAtUtc: string): TranscribedSegment[];
|
|
402
|
+
declare function resolveModelPath(configuredPath: string | undefined, defaultPath: string, exists?: (path: string) => boolean): string;
|
|
403
|
+
declare function buildWhisperArgs(wavPath: string, modelPath: string, threads?: number | null): string[];
|
|
404
|
+
declare function transcribeWithWhisper(input: WhisperTranscriptionInput): Promise<TranscribedSegment[]>;
|
|
405
|
+
declare function runWhisperCli(command: string, args: string[]): Promise<WhisperRunResult>;
|
|
406
|
+
|
|
407
|
+
type ModelFetch = (url: string) => Promise<Response>;
|
|
408
|
+
interface ModelDownloadInput {
|
|
409
|
+
model: string;
|
|
410
|
+
directory: string;
|
|
411
|
+
fetch?: ModelFetch;
|
|
412
|
+
}
|
|
413
|
+
interface ModelDownloadResult {
|
|
414
|
+
path: string;
|
|
415
|
+
downloaded: boolean;
|
|
416
|
+
}
|
|
417
|
+
declare function whisperModelUrl(model: string): string;
|
|
418
|
+
declare function downloadWhisperModel(input: ModelDownloadInput): Promise<ModelDownloadResult>;
|
|
419
|
+
|
|
420
|
+
declare function pruneExpiredRawAudio(rawDirectory: string, retentionMs: number, nowMs?: number): Promise<string[]>;
|
|
421
|
+
|
|
422
|
+
interface SileroVadInput {
|
|
423
|
+
modelPath: string;
|
|
424
|
+
minSpeechMs: number;
|
|
425
|
+
minSilenceMs?: number;
|
|
426
|
+
maxSpeechMs?: number;
|
|
427
|
+
threshold?: number;
|
|
428
|
+
threads?: number;
|
|
429
|
+
}
|
|
430
|
+
interface SherpaOnnxModule {
|
|
431
|
+
Vad: new (config: unknown, bufferSeconds: number) => unknown;
|
|
432
|
+
}
|
|
433
|
+
declare function sileroVadConfig(input: SileroVadInput): {
|
|
434
|
+
config: object;
|
|
435
|
+
bufferSeconds: number;
|
|
436
|
+
};
|
|
437
|
+
declare function loadSherpaOnnx(importModule?: (specifier: string) => Promise<unknown>): Promise<SherpaOnnxModule>;
|
|
438
|
+
declare function createSileroVad(input: SileroVadInput, load?: () => Promise<SherpaOnnxModule>, exists?: (path: string) => boolean): Promise<unknown>;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Loopback-only HTTP daemon. Serves the spool over three read-only routes:
|
|
442
|
+
*
|
|
443
|
+
* GET /v1/health → liveness + capture status + instanceId
|
|
444
|
+
* GET /v1/conversations → final conversations for a local day (keyset paged)
|
|
445
|
+
* GET /v1/speakers → speaker clusters (curation aid)
|
|
446
|
+
*
|
|
447
|
+
* Security: capture-audio serves PLAIN HTTP and has no TLS contract, so it
|
|
448
|
+
* refuses to bind a non-loopback host — transcript data must never cross
|
|
449
|
+
* the network in cleartext (a remote reader must front it with their own
|
|
450
|
+
* TLS/tunnel, out of scope here). Every request MUST carry
|
|
451
|
+
* `Authorization: Bearer <token>` matching the daemon token, even on
|
|
452
|
+
* loopback, so another local user cannot read transcripts off 127.0.0.1.
|
|
453
|
+
* Input errors are 400; anything unexpected is 500 with no foreign text.
|
|
454
|
+
*/
|
|
455
|
+
|
|
456
|
+
interface DaemonDeps {
|
|
457
|
+
spool: Spool;
|
|
458
|
+
config: DaemonConfig;
|
|
459
|
+
token: string;
|
|
460
|
+
/** Live capture status for /v1/health; a getter is re-read per request so it tracks the live runner. */
|
|
461
|
+
capturing?: boolean | (() => boolean);
|
|
462
|
+
}
|
|
463
|
+
interface DaemonHandle {
|
|
464
|
+
server: http.Server;
|
|
465
|
+
host: string;
|
|
466
|
+
port: number;
|
|
467
|
+
url: string;
|
|
468
|
+
close(): Promise<void>;
|
|
469
|
+
}
|
|
470
|
+
declare function createRequestHandler(deps: DaemonDeps): http.RequestListener;
|
|
471
|
+
declare function startDaemon(deps: DaemonDeps): Promise<DaemonHandle>;
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Daemon process control: an atomic, identity-bearing pid file plus
|
|
475
|
+
* liveness probing.
|
|
476
|
+
*
|
|
477
|
+
* The pid file is JSON `{ pid, instanceId, startedAtIso }` written via a
|
|
478
|
+
* temp-file + rename so a reader never sees a partial write, and reads are
|
|
479
|
+
* tolerant of a concurrent delete. `instanceId` (the spool instance id)
|
|
480
|
+
* lets `stop`/`status` confirm — over the authenticated health endpoint —
|
|
481
|
+
* that the recorded pid really is our daemon before signalling it, which
|
|
482
|
+
* guards against PID reuse. Removal is owner-checked so a late shutdown
|
|
483
|
+
* can't delete a newer daemon's control file.
|
|
484
|
+
*/
|
|
485
|
+
interface PidRecord {
|
|
486
|
+
pid: number;
|
|
487
|
+
/** Daemon instance id (spool instance_id) for cross-process identity; null when unknown. */
|
|
488
|
+
instanceId: string | null;
|
|
489
|
+
/** ISO timestamp the record was written. */
|
|
490
|
+
startedAtIso: string;
|
|
491
|
+
/** Effective bound host, when known (so status/stop reach the daemon the CLI actually started). */
|
|
492
|
+
host: string | null;
|
|
493
|
+
/** Effective bound port, when known. */
|
|
494
|
+
port: number | null;
|
|
495
|
+
}
|
|
496
|
+
interface PidWriteOptions {
|
|
497
|
+
instanceId?: string | null;
|
|
498
|
+
startedAtIso?: string;
|
|
499
|
+
host?: string | null;
|
|
500
|
+
port?: number | null;
|
|
501
|
+
}
|
|
502
|
+
/** Atomically write the pid record (temp file + rename) — no partial reads. */
|
|
503
|
+
declare function writePidFile(pidPath: string, pid: number, options?: PidWriteOptions): void;
|
|
504
|
+
/** Read the pid record; a missing file or a partial/concurrent write returns null. */
|
|
505
|
+
declare function readPidRecord(pidPath: string): PidRecord | null;
|
|
506
|
+
/** Convenience accessor: the recorded pid, or null. */
|
|
507
|
+
declare function readPidFile(pidPath: string): number | null;
|
|
508
|
+
/** Liveness via signal 0. ESRCH → gone; EPERM → alive but owned by another user. */
|
|
509
|
+
declare function isProcessAlive(pid: number): boolean;
|
|
510
|
+
/** Remove the pid file unconditionally (stale reclaim). */
|
|
511
|
+
declare function removePidFile(pidPath: string): void;
|
|
512
|
+
/**
|
|
513
|
+
* Remove the pid file only when it still records `pid`. Prevents a late
|
|
514
|
+
* shutdown or `stop` from deleting a NEWER daemon's control file after a
|
|
515
|
+
* restart or PID reuse.
|
|
516
|
+
*/
|
|
517
|
+
declare function removePidFileIfOwner(pidPath: string, pid: number): void;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* `remnic-capture-audio` CLI. Subcommands: init, start, stop, status,
|
|
521
|
+
* devices, logs. `start --replay <dir>` feeds synthetic fixtures through
|
|
522
|
+
* the spool + HTTP API (the CI-friendly, hardware-free path). Native
|
|
523
|
+
* device enumeration and real capture arrive in later checklist items;
|
|
524
|
+
* `devices` reports that honestly rather than faking a device list.
|
|
525
|
+
*/
|
|
526
|
+
|
|
527
|
+
interface CliIo {
|
|
528
|
+
argv: string[];
|
|
529
|
+
env?: NodeJS.ProcessEnv;
|
|
530
|
+
stdout?: (line: string) => void;
|
|
531
|
+
downloadModel?: (input: ModelDownloadInput) => Promise<ModelDownloadResult>;
|
|
532
|
+
stderr?: (line: string) => void;
|
|
533
|
+
/**
|
|
534
|
+
* argv tokens (after the node executable) that re-launch THIS CLI, used
|
|
535
|
+
* when the daemon backgrounds itself into `--foreground`. Defaults to
|
|
536
|
+
* [process.argv[1]] (direct `remnic-capture-audio` invocation). The
|
|
537
|
+
* `remnic capture audio` passthrough supplies [remnicBin, "capture",
|
|
538
|
+
* "audio"] so the detached child is `remnic capture audio start
|
|
539
|
+
* --foreground`, not `remnic start --foreground`.
|
|
540
|
+
*/
|
|
541
|
+
spawnArgvPrefix?: string[];
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Run replay ingestion as a supervised task AFTER the daemon is ready. Never
|
|
545
|
+
* throws: success/failure is surfaced via the spool's `replay_status` meta
|
|
546
|
+
* (also exposed on /v1/health) and the daemon log, so a failed or slow replay
|
|
547
|
+
* never kills the daemon or retracts its readiness.
|
|
548
|
+
*/
|
|
549
|
+
declare function superviseReplay(spool: Spool, replayDir: string, io: {
|
|
550
|
+
stdout: (l: string) => void;
|
|
551
|
+
stderr: (l: string) => void;
|
|
552
|
+
}, signal?: AbortSignal): Promise<void>;
|
|
553
|
+
declare function runCapture(io: CliIo): Promise<number>;
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* `desktop` wearable source connector (issue #1897, component 4).
|
|
557
|
+
*
|
|
558
|
+
* À-la-carte optional companion of @remnic/core: installing core alone
|
|
559
|
+
* never pulls this in; core discovers it at runtime via a
|
|
560
|
+
* computed-specifier dynamic import (registry entry {id:"desktop",
|
|
561
|
+
* suffix:"capture-audio"}) or via a direct import of @remnic/capture-audio,
|
|
562
|
+
* which self-registers idempotently.
|
|
563
|
+
*
|
|
564
|
+
* The connector is a pure API client + normalizer over the capture-audio
|
|
565
|
+
* daemon's loopback HTTP API: no file IO beyond reading the local token,
|
|
566
|
+
* no memory writes, no pipeline behavior (all of that stays in core so
|
|
567
|
+
* desktop audio gets the same cleanup/corrections/trust gating as every
|
|
568
|
+
* other wearable source).
|
|
569
|
+
*
|
|
570
|
+
* Token resolution (in order): settings.apiKey (config) ->
|
|
571
|
+
* REMNIC_CAPTURE_AUDIO_TOKEN env -> the daemon's local token file
|
|
572
|
+
* (~/.remnic/capture/token) when the base URL is loopback.
|
|
573
|
+
*/
|
|
574
|
+
|
|
575
|
+
declare const DESKTOP_SOURCE_ID = "desktop";
|
|
576
|
+
/** Error raised for a genuine backend failure (never for an empty day). */
|
|
577
|
+
declare class DesktopDaemonError extends Error {
|
|
578
|
+
constructor(message: string);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Resolve the daemon bearer token. The local token file is read ONLY for
|
|
582
|
+
* a loopback base URL — a remote reader must supply the token explicitly
|
|
583
|
+
* (config/env), never inherit this machine's local token.
|
|
584
|
+
*/
|
|
585
|
+
declare function resolveCaptureAudioToken(configured: string | undefined, baseUrl: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
586
|
+
declare function daemonConversationToWearable(conv: DaemonConversation): WearableConversation;
|
|
587
|
+
declare function createDesktopConnector(options: WearableConnectorFactoryOptions): WearableSourceConnector;
|
|
588
|
+
declare const wearableConnectorRegistration: WearableConnectorRegistration;
|
|
589
|
+
/** Idempotently register the desktop connector with the core registry. */
|
|
590
|
+
declare function ensureDesktopConnectorRegistered(): boolean;
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Cross-channel dedup (issue #1897, component 2.4).
|
|
594
|
+
*
|
|
595
|
+
* A speakerphone is heard twice: once on the mic and once on the system
|
|
596
|
+
* (loopback) channel. When the mic and system channels transcribe
|
|
597
|
+
* near-identical text in overlapping time, keep the SYSTEM copy (the
|
|
598
|
+
* cleaner far-end signal) and drop the mic copy. Match rule: word-level
|
|
599
|
+
* Jaccard >= 0.8 within +-5 s. Pure over segment arrays so the pipeline
|
|
600
|
+
* and the unit tests share one implementation.
|
|
601
|
+
*/
|
|
602
|
+
/** Minimum shape needed to dedup; the pipeline's richer segments satisfy it. */
|
|
603
|
+
interface DedupSegment {
|
|
604
|
+
channel: string;
|
|
605
|
+
text: string;
|
|
606
|
+
startUtc: string;
|
|
607
|
+
endUtc: string;
|
|
608
|
+
}
|
|
609
|
+
declare function wordJaccard(a: string, b: string): number;
|
|
610
|
+
/**
|
|
611
|
+
* Drop mic segments that duplicate a system segment (overlapping time +
|
|
612
|
+
* Jaccard >= threshold). System segments and non-duplicate mic segments
|
|
613
|
+
* are preserved in input order. Generic so callers keep their richer type.
|
|
614
|
+
*/
|
|
615
|
+
declare function dedupeCrossChannel<T extends DedupSegment>(segments: readonly T[], options?: {
|
|
616
|
+
toleranceMs?: number;
|
|
617
|
+
jaccardThreshold?: number;
|
|
618
|
+
}): T[];
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Conversation assembly (issue #1897, component 2.5).
|
|
622
|
+
*
|
|
623
|
+
* A conversation is a maximal run of consecutive speech segments whose
|
|
624
|
+
* inter-segment gap stays below `conversationGapMinutes`. A gap greater
|
|
625
|
+
* than OR EQUAL to the threshold starts a new conversation (the join rule
|
|
626
|
+
* is strictly `gap < threshold`, per the issue). Pure over ordered
|
|
627
|
+
* segments so the daemon pipeline and unit tests share one implementation;
|
|
628
|
+
* output rows map 1:1 to Spool.insertConversation input.
|
|
629
|
+
*/
|
|
630
|
+
|
|
631
|
+
/** A segment as it enters assembly (post dedup + diarization). */
|
|
632
|
+
type AssemblySegment = SegmentInput;
|
|
633
|
+
/**
|
|
634
|
+
* Group ordered segments into conversations. Segments MUST already be in
|
|
635
|
+
* chronological order (the pipeline emits them that way). Each returned
|
|
636
|
+
* row omits `id` so Spool.insertConversation mints a `conv_<ulid>`.
|
|
637
|
+
*
|
|
638
|
+
* `gapMinutes` is the max silence that keeps two segments in the same
|
|
639
|
+
* conversation; `state` is applied to every produced conversation
|
|
640
|
+
* (default "final" — the API only serves final; callers pass "capturing"
|
|
641
|
+
* for the still-open tail).
|
|
642
|
+
*/
|
|
643
|
+
declare function assembleConversations(segments: readonly AssemblySegment[], gapMinutes: number, state?: ConversationState): ConversationInput[];
|
|
644
|
+
/** Default per issue #1897 config surface. */
|
|
645
|
+
declare const DEFAULT_CONVERSATION_GAP_MINUTES = 10;
|
|
646
|
+
/** A conversation the stateful assembler is building incrementally. */
|
|
647
|
+
interface AssembledConversation {
|
|
648
|
+
id: string;
|
|
649
|
+
startedAtUtc: string;
|
|
650
|
+
endedAtUtc: string;
|
|
651
|
+
state: ConversationState;
|
|
652
|
+
segments: AssemblySegment[];
|
|
653
|
+
}
|
|
654
|
+
interface AssemblerOptions {
|
|
655
|
+
gapMinutes?: number;
|
|
656
|
+
/** Injectable for deterministic ids in tests; defaults to `conv_<ulid>`. */
|
|
657
|
+
makeId?: () => string;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Incremental sibling of `assembleConversations` for the live daemon: feed
|
|
661
|
+
* segments one chunk at a time and it groups them into conversations under the
|
|
662
|
+
* same `gap < threshold` rule, tracking a single open (`capturing`)
|
|
663
|
+
* conversation. The batch function stays the source of truth for replay; this
|
|
664
|
+
* class owns the streaming case. Pure in-memory — the processor decides when to
|
|
665
|
+
* persist and provides restart continuity via `resume`.
|
|
666
|
+
*/
|
|
667
|
+
declare class ConversationAssembler {
|
|
668
|
+
#private;
|
|
669
|
+
constructor(options?: AssemblerOptions);
|
|
670
|
+
/**
|
|
671
|
+
* Append one segment, returning the conversation it landed in. Segments
|
|
672
|
+
* arrive in non-decreasing start order; a gap of at least the threshold
|
|
673
|
+
* closes the open conversation and starts a new one.
|
|
674
|
+
*/
|
|
675
|
+
add(segment: AssemblySegment): AssembledConversation;
|
|
676
|
+
/** Flip every open (`capturing`) conversation to `final`; returns the count changed. */
|
|
677
|
+
finalize(): number;
|
|
678
|
+
/**
|
|
679
|
+
* Re-open a conversation recovered from durable storage so a chunk arriving
|
|
680
|
+
* after a process restart continues it (subject to the same gap rule via
|
|
681
|
+
* `add`) instead of splitting off a new one. No-op when a conversation is
|
|
682
|
+
* already open in this run.
|
|
683
|
+
*/
|
|
684
|
+
resume(conversation: {
|
|
685
|
+
id: string;
|
|
686
|
+
startedAtUtc: string;
|
|
687
|
+
endedAtUtc: string;
|
|
688
|
+
}): void;
|
|
689
|
+
/** Ordered snapshot; segments are cloned so callers cannot mutate internal state. */
|
|
690
|
+
conversations(): AssembledConversation[];
|
|
691
|
+
/**
|
|
692
|
+
* Finalize the open conversation when `nowUtc` is at least the gap past its
|
|
693
|
+
* last segment, so a run of silent chunks (which carry no segments to `add`)
|
|
694
|
+
* still closes a conversation instead of leaving it `capturing` until stop.
|
|
695
|
+
* Returns the closed conversation's id, or null when nothing closed.
|
|
696
|
+
*/
|
|
697
|
+
closeIfIdle(nowUtc: string): string | null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Speaker diarization clustering (issue #1897, component 2.3).
|
|
702
|
+
*
|
|
703
|
+
* The daemon computes one speaker embedding per VAD speech segment (via
|
|
704
|
+
* the optional sherpa-onnx speaker-id model, wired with the native
|
|
705
|
+
* capture layer). This module owns the CPU-cheap, hardware-free half:
|
|
706
|
+
* matching an embedding to a stable speaker cluster and maintaining the
|
|
707
|
+
* cluster's running centroid + a bounded diverse example set. It is pure
|
|
708
|
+
* over embedding vectors so the fragmentation regression (one synthetic
|
|
709
|
+
* voice across many segments -> one cluster) runs in CI without models.
|
|
710
|
+
*
|
|
711
|
+
* Match score = best cosine similarity against BOTH the cluster centroid
|
|
712
|
+
* and up to `maxExamples` stored examples (issue: "take the best score").
|
|
713
|
+
*/
|
|
714
|
+
type Embedding = readonly number[];
|
|
715
|
+
interface SpeakerCluster {
|
|
716
|
+
id: string;
|
|
717
|
+
centroid: number[];
|
|
718
|
+
examples: number[][];
|
|
719
|
+
embeddingCount: number;
|
|
720
|
+
isSelf: boolean;
|
|
721
|
+
label: string | null;
|
|
722
|
+
}
|
|
723
|
+
declare function cosineSimilarity(a: Embedding, b: Embedding): number;
|
|
724
|
+
/**
|
|
725
|
+
* Assigns embeddings to stable speaker clusters. Ids are `spk_<n>` (or
|
|
726
|
+
* `self` for the enrolled wearer). Seed with persisted clusters so ids
|
|
727
|
+
* survive daemon restarts.
|
|
728
|
+
*/
|
|
729
|
+
declare class SpeakerClusterer {
|
|
730
|
+
#private;
|
|
731
|
+
constructor(threshold: number, seed?: readonly SpeakerCluster[]);
|
|
732
|
+
/** Register an enrolled self profile (its embedding seeds the `self` cluster). */
|
|
733
|
+
enrollSelf(embedding: Embedding): void;
|
|
734
|
+
/** Match `embedding` to an existing cluster or create a new `spk_<n>`. */
|
|
735
|
+
assign(embedding: Embedding): string;
|
|
736
|
+
/** Snapshot for persistence. */
|
|
737
|
+
clusters(): SpeakerCluster[];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Native capture helper resolver + supervised process runner (issue #1897,
|
|
742
|
+
* "audio native macOS helper" slice — Node side only).
|
|
743
|
+
*
|
|
744
|
+
* The native recorder is the ONE shared macOS helper shipped by #2138
|
|
745
|
+
* (`remnic-capture-helper`), driven here through its `audio-capture`
|
|
746
|
+
* subcommand. It emits one JSONL `ChunkEvent` per recorded WAV chunk on
|
|
747
|
+
* stdout. This module is deliberately à-la-carte, mirroring the VAD/STT
|
|
748
|
+
* adapters and the screen daemon's helper seam:
|
|
749
|
+
*
|
|
750
|
+
* - The helper ships as an OPTIONAL, per-platform package
|
|
751
|
+
* (`@remnic/capture-native-darwin-arm64` / `-x64`) that exports a
|
|
752
|
+
* `helperBinaryPath` and declares the same binary under `bin`. It is a
|
|
753
|
+
* peer dependency, never a runtime dependency, so `@remnic/capture-audio`
|
|
754
|
+
* installs and works on any platform without it.
|
|
755
|
+
* - The package specifier is COMPUTED from `process.platform`/`arch` so a
|
|
756
|
+
* static importer never bundles a foreign-arch binary, and resolution uses
|
|
757
|
+
* Node module resolution (`require.resolve`).
|
|
758
|
+
* - `REMNIC_CAPTURE_HELPER_BIN` overrides resolution with an explicit binary
|
|
759
|
+
* path (manual installs and the hardware-free test seam, which points it at
|
|
760
|
+
* a fake script emitting canned JSON).
|
|
761
|
+
* - A missing optional package reports the EXACT install command instead of a
|
|
762
|
+
* raw resolver error.
|
|
763
|
+
*
|
|
764
|
+
* The runner is the sole owner of the child process: it spawns the helper,
|
|
765
|
+
* parses stdout strictly line-by-line, reports validated events to a callback,
|
|
766
|
+
* reports stderr/errors separately, and restarts only UNEXPECTED exits with
|
|
767
|
+
* bounded exponential backoff. It never writes the Spool and never invents a
|
|
768
|
+
* conversation — the processing/assembly layer owns eventual Spool writes
|
|
769
|
+
* downstream of the validated events this runner surfaces.
|
|
770
|
+
*/
|
|
771
|
+
/** One recorded audio chunk, as emitted by the native helper on stdout (JSONL). */
|
|
772
|
+
interface ChunkEvent {
|
|
773
|
+
path: string;
|
|
774
|
+
channel: "mic" | "system";
|
|
775
|
+
startedAtUtc: string;
|
|
776
|
+
endedAtUtc: string;
|
|
777
|
+
device: string | null;
|
|
778
|
+
}
|
|
779
|
+
/** Which channels the `audio-capture` subcommand records. */
|
|
780
|
+
type ChannelSelection = "mic" | "system" | "both";
|
|
781
|
+
/** A resolved native helper: its source specifier and the on-disk binary path. */
|
|
782
|
+
interface HelperResolution {
|
|
783
|
+
specifier: string;
|
|
784
|
+
binaryPath: string;
|
|
785
|
+
}
|
|
786
|
+
/** The narrow child-process surface the runner depends on (injectable for tests). */
|
|
787
|
+
interface HelperChild {
|
|
788
|
+
stdout: {
|
|
789
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
790
|
+
};
|
|
791
|
+
stderr: {
|
|
792
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
793
|
+
};
|
|
794
|
+
once(event: "error", listener: (err: Error) => void): unknown;
|
|
795
|
+
once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
796
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
797
|
+
readonly killed?: boolean;
|
|
798
|
+
readonly pid?: number;
|
|
799
|
+
}
|
|
800
|
+
/** Spawns the helper binary. Defaults to a `node:child_process` adapter. */
|
|
801
|
+
type HelperSpawn = (binaryPath: string, args: string[]) => HelperChild;
|
|
802
|
+
/** An opaque restart-timer token returned by `scheduleRestart`. */
|
|
803
|
+
type RestartTimer = unknown;
|
|
804
|
+
interface ResolveHelperDeps {
|
|
805
|
+
platform?: NodeJS.Platform;
|
|
806
|
+
arch?: string;
|
|
807
|
+
/** `require.resolve`-style resolver; defaults to this module's require. */
|
|
808
|
+
resolve?: (specifier: string) => string;
|
|
809
|
+
readFile?: (file: string) => string;
|
|
810
|
+
/** Environment source for the `REMNIC_CAPTURE_HELPER_BIN` override. */
|
|
811
|
+
env?: NodeJS.ProcessEnv;
|
|
812
|
+
}
|
|
813
|
+
interface NativeRunnerOptions {
|
|
814
|
+
/** Directory the helper writes WAV chunks into (`audio-capture --out`). */
|
|
815
|
+
outDir: string;
|
|
816
|
+
chunkSeconds: number;
|
|
817
|
+
/** Channels to record; defaults to "both". */
|
|
818
|
+
channel?: ChannelSelection;
|
|
819
|
+
/** Optional CoreAudio microphone device UID (`--device`). */
|
|
820
|
+
device?: string | null;
|
|
821
|
+
/** Called once per validated ChunkEvent. */
|
|
822
|
+
onChunk: (event: ChunkEvent) => void;
|
|
823
|
+
/** Called for a rejected stdout line or a spawn/child error. */
|
|
824
|
+
onError?: (error: Error) => void;
|
|
825
|
+
/** Called once per complete stderr line. */
|
|
826
|
+
onStderr?: (line: string) => void;
|
|
827
|
+
/** Pre-resolved helper; when absent the runner resolves it lazily on `start()`. */
|
|
828
|
+
resolution?: HelperResolution;
|
|
829
|
+
resolveBinary?: (deps: ResolveHelperDeps) => HelperResolution;
|
|
830
|
+
spawn?: HelperSpawn;
|
|
831
|
+
/** Max consecutive unexpected restarts before giving up (default 5). */
|
|
832
|
+
maxRestarts?: number;
|
|
833
|
+
/** First backoff delay in ms (default 500). */
|
|
834
|
+
baseBackoffMs?: number;
|
|
835
|
+
/** Backoff ceiling in ms (default 30000). */
|
|
836
|
+
maxBackoffMs?: number;
|
|
837
|
+
scheduleRestart?: (fn: () => void, delayMs: number) => RestartTimer;
|
|
838
|
+
cancelRestart?: (timer: RestartTimer) => void;
|
|
839
|
+
}
|
|
840
|
+
/** A running native-capture supervisor. */
|
|
841
|
+
interface NativeCaptureRunner {
|
|
842
|
+
start(): void;
|
|
843
|
+
/** Stop the helper (SIGTERM) and resolve once it exits and its final chunk is read. */
|
|
844
|
+
stop(): Promise<void>;
|
|
845
|
+
/** True between a `start()` and its matching `stop()`. */
|
|
846
|
+
readonly running: boolean;
|
|
847
|
+
}
|
|
848
|
+
/** The env var that overrides package resolution with an explicit binary path. */
|
|
849
|
+
declare const HELPER_BIN_ENV = "REMNIC_CAPTURE_HELPER_BIN";
|
|
850
|
+
/**
|
|
851
|
+
* Compute the optional native-helper package specifier for a platform/arch.
|
|
852
|
+
* The helper is macOS-only and hardware-gated; every other platform (and any
|
|
853
|
+
* unsupported macOS architecture) throws loudly rather than resolving to a
|
|
854
|
+
* package that cannot exist.
|
|
855
|
+
*/
|
|
856
|
+
declare function helperPackageSpecifier(platform: NodeJS.Platform | string, arch: string): string;
|
|
857
|
+
/**
|
|
858
|
+
* Resolve the native helper binary. Order: explicit `REMNIC_CAPTURE_HELPER_BIN`
|
|
859
|
+
* override, then the computed platform package's declared executable (resolved
|
|
860
|
+
* via Node module resolution, identical to its `helperBinaryPath` export).
|
|
861
|
+
* Throws a CaptureConfigError naming the exact install command when the
|
|
862
|
+
* optional package is not installed.
|
|
863
|
+
*/
|
|
864
|
+
declare function resolveHelperBinary(deps?: ResolveHelperDeps): HelperResolution;
|
|
865
|
+
/** Build the `audio-capture` argv from runner options (#2138 helper contract). */
|
|
866
|
+
declare function buildHelperArgs(opts: Pick<NativeRunnerOptions, "outDir" | "chunkSeconds" | "channel" | "device">): string[];
|
|
867
|
+
/** Parse and validate one JSONL line into a ChunkEvent; throws on anything malformed. */
|
|
868
|
+
declare function parseChunkEvent(line: string): ChunkEvent;
|
|
869
|
+
/**
|
|
870
|
+
* Run the helper's one-shot `device-enumerate` subcommand and return the parsed
|
|
871
|
+
* device list. Bounded, argv-only, and injectable for tests. Throws a
|
|
872
|
+
* CaptureInputError on a nonzero exit, empty output, or invalid JSON.
|
|
873
|
+
*/
|
|
874
|
+
declare function enumerateDevices(binaryPath: string, spawn?: HelperSpawn, timeoutMs?: number): Promise<unknown[]>;
|
|
875
|
+
/**
|
|
876
|
+
* Create a supervised native-capture runner. Dependency-injectable: pass
|
|
877
|
+
* `spawn`, `resolution`/`resolveBinary`, and `scheduleRestart`/`cancelRestart`
|
|
878
|
+
* to drive it deterministically in tests.
|
|
879
|
+
*/
|
|
880
|
+
declare function createNativeCaptureRunner(options: NativeRunnerOptions): NativeCaptureRunner;
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Chunk processor (issue #1897) — turns completed native WAV chunk events
|
|
884
|
+
* into durable, replay-safe conversations in the spool.
|
|
885
|
+
*
|
|
886
|
+
* The native helper runner owns process lifecycle and emits one validated
|
|
887
|
+
* `ChunkEvent` per recorded WAV. This module consumes those events through a
|
|
888
|
+
* single serialized promise chain: resolve model -> transcribe -> normalize
|
|
889
|
+
* nonempty segments -> assemble -> persist via durable chunk idempotency ->
|
|
890
|
+
* delete the raw WAV. A rejected chunk is reported and the chain recovers so
|
|
891
|
+
* the daemon stays alive; the durable `applied_chunks` guard keeps a
|
|
892
|
+
* restart/replay of the same chunk from duplicating segments.
|
|
893
|
+
*
|
|
894
|
+
* Every collaborator (STT, model resolution, raw-audio cleanup) is injected,
|
|
895
|
+
* so no optional VAD/native runtime is imported here and the package stays
|
|
896
|
+
* à-la-carte.
|
|
897
|
+
*/
|
|
898
|
+
|
|
899
|
+
interface ChunkTranscribeInput {
|
|
900
|
+
wavPath: string;
|
|
901
|
+
modelPath: string;
|
|
902
|
+
chunkStartedAtUtc: string;
|
|
903
|
+
}
|
|
904
|
+
interface ChunkProcessorDeps {
|
|
905
|
+
spool: Spool;
|
|
906
|
+
/** Stateful assembler that groups consecutive segments into conversations. */
|
|
907
|
+
assembler: ConversationAssembler;
|
|
908
|
+
/** Resolve the STT model path; called per speech chunk and may throw when absent. */
|
|
909
|
+
resolveModel: () => string;
|
|
910
|
+
/** Transcribe one WAV chunk into raw segments. */
|
|
911
|
+
transcribe: (input: ChunkTranscribeInput) => Promise<TranscribedSegment[]>;
|
|
912
|
+
/** Delete the raw WAV under retention once the chunk is durably persisted. */
|
|
913
|
+
cleanupRawAudio: (event: ChunkEvent) => Promise<void>;
|
|
914
|
+
/**
|
|
915
|
+
* VAD speech gate. When provided and it resolves false, the chunk is treated
|
|
916
|
+
* as non-speech: STT is skipped (the CPU-budget guard) and no segments
|
|
917
|
+
* persist. Absent -> every chunk is transcribed.
|
|
918
|
+
*/
|
|
919
|
+
detectSpeech?: (event: ChunkEvent) => boolean | Promise<boolean>;
|
|
920
|
+
/**
|
|
921
|
+
* Speaker-embedding extractor for diarization. With `diarizer`, each segment
|
|
922
|
+
* is embedded and assigned to a speaker cluster; absent -> the interim
|
|
923
|
+
* mic=wearer heuristic and no speaker cluster.
|
|
924
|
+
*/
|
|
925
|
+
embed?: (event: ChunkEvent, segment: TranscribedSegment) => Embedding | Promise<Embedding>;
|
|
926
|
+
/** Speaker clusterer (seeded from the spool); its clusters are persisted on finalize. */
|
|
927
|
+
diarizer?: SpeakerClusterer;
|
|
928
|
+
/** Cross-channel dedup window in ms; defaults to the dedup module's tolerance. */
|
|
929
|
+
dedupWindowMs?: number;
|
|
930
|
+
/** Reports a per-chunk failure; the chain keeps running afterwards. */
|
|
931
|
+
onError?: (error: Error, event: ChunkEvent) => void;
|
|
932
|
+
}
|
|
933
|
+
interface ChunkProcessor {
|
|
934
|
+
/** onChunk seam for `NativeRunnerOptions`. Never throws; failures route to `onError`. */
|
|
935
|
+
enqueue(event: ChunkEvent): void;
|
|
936
|
+
/** Resolve once the serialized chain has settled all enqueued chunks. */
|
|
937
|
+
drain(): Promise<void>;
|
|
938
|
+
/** Drain, then flip open conversations to `final`; returns the count closed. */
|
|
939
|
+
finalize(): Promise<number>;
|
|
940
|
+
}
|
|
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
|
+
declare function chunkStableId(event: ChunkEvent): string;
|
|
947
|
+
declare function createChunkProcessor(deps: ChunkProcessorDeps): ChunkProcessor;
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Live capture wiring (issue #1897) — assembles the native helper runner and
|
|
951
|
+
* the chunk processor into one start/stop unit the daemon drives.
|
|
952
|
+
*
|
|
953
|
+
* The native runner owns the helper process and surfaces validated
|
|
954
|
+
* `ChunkEvent`s; the processor turns each recorded WAV into durable, replay-safe
|
|
955
|
+
* conversations in the spool. This module wires them with production defaults
|
|
956
|
+
* (whisper STT, model resolution, raw-audio cleanup) while keeping every
|
|
957
|
+
* collaborator injectable, so tests drive the whole pipeline against a fake
|
|
958
|
+
* helper binary and a fake transcriber without any macOS runtime.
|
|
959
|
+
*/
|
|
960
|
+
|
|
961
|
+
interface LiveCaptureOptions {
|
|
962
|
+
spool: Spool;
|
|
963
|
+
config: DaemonConfig;
|
|
964
|
+
/** Directory the helper writes WAV chunks into (`audio-capture --out`). */
|
|
965
|
+
outDir: string;
|
|
966
|
+
/** Default whisper model path when `config.stt.modelPath` is unset. */
|
|
967
|
+
defaultModelPath: string;
|
|
968
|
+
onError?: (error: Error) => void;
|
|
969
|
+
onStderr?: (line: string) => void;
|
|
970
|
+
spawn?: HelperSpawn;
|
|
971
|
+
resolveBinary?: (deps: ResolveHelperDeps) => HelperResolution;
|
|
972
|
+
resolution?: HelperResolution;
|
|
973
|
+
transcribe?: (input: ChunkTranscribeInput) => Promise<TranscribedSegment[]>;
|
|
974
|
+
resolveModel?: () => string;
|
|
975
|
+
cleanupRawAudio?: (event: ChunkEvent) => Promise<void>;
|
|
976
|
+
scheduleRestart?: (fn: () => void, delayMs: number) => RestartTimer;
|
|
977
|
+
cancelRestart?: (timer: RestartTimer) => void;
|
|
978
|
+
makeConversationId?: () => string;
|
|
979
|
+
/** VAD speech gate seam; production supplies a sherpa-onnx detector. */
|
|
980
|
+
detectSpeech?: (event: ChunkEvent) => boolean | Promise<boolean>;
|
|
981
|
+
/** Speaker-embedding seam for diarization; production supplies sherpa speaker-id. */
|
|
982
|
+
embed?: (event: ChunkEvent, segment: TranscribedSegment) => Embedding | Promise<Embedding>;
|
|
983
|
+
}
|
|
984
|
+
interface LiveCapture {
|
|
985
|
+
start(): void;
|
|
986
|
+
/** Stop the helper, then drain + finalize the processor. */
|
|
987
|
+
stop(): Promise<number>;
|
|
988
|
+
readonly running: boolean;
|
|
989
|
+
/** Test/observability seam. */
|
|
990
|
+
readonly processor: ChunkProcessor;
|
|
991
|
+
}
|
|
992
|
+
/** Wire the native runner + chunk processor into one live-capture unit. */
|
|
993
|
+
declare function createLiveCapture(options: LiveCaptureOptions): LiveCapture;
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* `install-service` support (issue #1897) — render and install a per-user
|
|
997
|
+
* background service that runs the capture-audio daemon in live-capture mode.
|
|
998
|
+
*
|
|
999
|
+
* macOS uses a launchd LaunchAgent (`~/Library/LaunchAgents/<label>.plist`);
|
|
1000
|
+
* Linux uses a systemd user unit (`~/.config/systemd/user/<name>.service`).
|
|
1001
|
+
* The renderers are pure (deterministic strings) and `installService` /
|
|
1002
|
+
* `uninstallService` take injected filesystem + environment seams so the whole
|
|
1003
|
+
* surface is testable without touching the real user launch directories.
|
|
1004
|
+
*/
|
|
1005
|
+
declare const DEFAULT_SERVICE_LABEL = "com.remnic.capture-audio";
|
|
1006
|
+
interface ServiceSpec {
|
|
1007
|
+
/** argv that launches the daemon, e.g. [node, cliEntry, "start", "--foreground", "--capture"]. */
|
|
1008
|
+
programArguments: string[];
|
|
1009
|
+
logPath: string;
|
|
1010
|
+
label?: string;
|
|
1011
|
+
/** Env vars the launched daemon needs (e.g. PATH, REMNIC_CAPTURE_HELPER_BIN). */
|
|
1012
|
+
environment?: Record<string, string>;
|
|
1013
|
+
}
|
|
1014
|
+
interface ServicePlan {
|
|
1015
|
+
platform: NodeJS.Platform;
|
|
1016
|
+
/** Absolute path the unit file is written to. */
|
|
1017
|
+
path: string;
|
|
1018
|
+
contents: string;
|
|
1019
|
+
/** One-line operator instruction to load/enable the service. */
|
|
1020
|
+
loadHint: string;
|
|
1021
|
+
}
|
|
1022
|
+
/** Render a launchd LaunchAgent plist that keeps the daemon alive at login. */
|
|
1023
|
+
declare function renderLaunchAgent(spec: ServiceSpec): string;
|
|
1024
|
+
/** Render a systemd user unit that restarts the daemon on failure. */
|
|
1025
|
+
declare function renderSystemdUnit(spec: ServiceSpec): string;
|
|
1026
|
+
interface PlanServiceDeps {
|
|
1027
|
+
platform: NodeJS.Platform;
|
|
1028
|
+
home: string;
|
|
1029
|
+
spec: ServiceSpec;
|
|
1030
|
+
}
|
|
1031
|
+
/** Decide the unit file path + contents for the current platform. */
|
|
1032
|
+
declare function planService(deps: PlanServiceDeps): ServicePlan;
|
|
1033
|
+
interface InstallServiceDeps extends PlanServiceDeps {
|
|
1034
|
+
mkdir: (dir: string) => void;
|
|
1035
|
+
writeFile: (file: string, contents: string) => void;
|
|
1036
|
+
/** True to overwrite an already-installed unit. */
|
|
1037
|
+
force?: boolean;
|
|
1038
|
+
exists?: (file: string) => boolean;
|
|
1039
|
+
}
|
|
1040
|
+
/** Write the planned unit file, creating its directory. Returns the plan. */
|
|
1041
|
+
declare function installService(deps: InstallServiceDeps): ServicePlan;
|
|
1042
|
+
interface UninstallServiceDeps extends PlanServiceDeps {
|
|
1043
|
+
remove: (file: string) => void;
|
|
1044
|
+
exists: (file: string) => boolean;
|
|
1045
|
+
}
|
|
1046
|
+
/** Remove the installed unit file. Returns the plan + whether a file was removed. */
|
|
1047
|
+
declare function uninstallService(deps: UninstallServiceDeps): {
|
|
1048
|
+
plan: ServicePlan;
|
|
1049
|
+
removed: boolean;
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* `enroll-self` (issue #1897) — register the wearer as the `self` speaker so
|
|
1054
|
+
* the diarizer and downstream attribution can distinguish the wearer's own
|
|
1055
|
+
* voice from everyone else's.
|
|
1056
|
+
*
|
|
1057
|
+
* Enrollment stores a durable `self` speaker row. When a voice embedding is
|
|
1058
|
+
* supplied (extracted from a recorded sample by a speaker-embedding model —
|
|
1059
|
+
* à-la-carte, like whisper STT and the Silero VAD), it is stored as the self
|
|
1060
|
+
* centroid so live diarization can match against it. Without an embedder the
|
|
1061
|
+
* wearer's identity is still registered (embedding refinement lands with the
|
|
1062
|
+
* diarization slice), so the pipeline can already tag `desktop:self`.
|
|
1063
|
+
*
|
|
1064
|
+
* Pure over its injected `spool`: the caller owns recording the sample and
|
|
1065
|
+
* extracting the embedding, so no optional native/model runtime is imported.
|
|
1066
|
+
*/
|
|
1067
|
+
|
|
1068
|
+
/** The stable speaker id for the enrolled wearer. */
|
|
1069
|
+
declare const SELF_SPEAKER_ID = "self";
|
|
1070
|
+
interface EnrollSelfInput {
|
|
1071
|
+
spool: Spool;
|
|
1072
|
+
/** Human label for the wearer; defaults to "You". */
|
|
1073
|
+
label?: string | null;
|
|
1074
|
+
/** Optional wearer voice embedding; when present it is stored as the self centroid. */
|
|
1075
|
+
embedding?: Embedding;
|
|
1076
|
+
}
|
|
1077
|
+
interface EnrollSelfResult {
|
|
1078
|
+
speakerId: string;
|
|
1079
|
+
label: string | null;
|
|
1080
|
+
hasEmbedding: boolean;
|
|
1081
|
+
dimensions: number;
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Register (or refresh) the `self` speaker. With an embedding, the canonical
|
|
1085
|
+
* self cluster (centroid + example) is persisted; without one, only the
|
|
1086
|
+
* identity is upserted, preserving any embedding a prior enroll stored.
|
|
1087
|
+
*/
|
|
1088
|
+
declare function enrollSelf(input: EnrollSelfInput): EnrollSelfResult;
|
|
1089
|
+
|
|
1090
|
+
export { type AssembledConversation, type AssemblerOptions, type AssemblyAppendInput, type AssemblyAppendResult, type AssemblySegment, CAPTURE_AUDIO_VERSION, CaptureConfigError, CaptureInputError, type CapturePaths, type ChannelSelection, type ChunkEvent, type ChunkProcessor, type ChunkProcessorDeps, type ChunkStatus, type ChunkTranscribeInput, type CliIo, ConversationAssembler, type ConversationInput, type ConversationPage, type ConversationState, type Cursor, DEFAULT_CONVERSATION_GAP_MINUTES, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SERVICE_LABEL, DESKTOP_SOURCE_ID, type DaemonConfig, type DaemonConversation, type DaemonDeps, type DaemonHandle, type DaemonSegment, type DedupSegment, DesktopDaemonError, type DeviceConfig, type DiarizationConfig, type Embedding, type EnrollSelfInput, type EnrollSelfResult, HELPER_BIN_ENV, type HelperResolution, type HelperSpawn, type LiveCapture, type LiveCaptureOptions, type ModelDownloadInput, type ModelDownloadResult, type NativeCaptureRunner, type NativeRunnerOptions, type PidRecord, type QueryFinalOptions, REPLAY_COMMIT_BATCH, type ReplayResult, type ResolveHelperDeps, SELF_SPEAKER_ID, SPOOL_SCHEMA_VERSION, type SegmentInput, type ServicePlan, type ServiceSpec, type SherpaOnnxModule, type SileroVadInput, type SpeakerCluster, type SpeakerClusterRow, SpeakerClusterer, type SpeakerInput, type SpeakerRow, Spool, type SttConfig, type TranscribedSegment, type VadConfig, type WhisperRunResult, type WhisperTranscriptionInput, assembleConversations, assertValidTimezone, bearerFromHeader, buildHelperArgs, buildWhisperArgs, captureBaseDir, capturePaths, chunkStableId, cosineSimilarity, createChunkProcessor, createDesktopConnector, createLiveCapture, createNativeCaptureRunner, createRequestHandler, createSileroVad, daemonConversationToWearable, decodeCursor, dedupeCrossChannel, defaultDaemonConfig, downloadWhisperModel, encodeCursor, enrollSelf, ensureDesktopConnectorRegistered, enumerateDevices, generateToken, helperPackageSpecifier, ingestReplayDir, ingestReplayDirResponsive, installService, isProcessAlive, loadDaemonConfig, loadOrCreateToken, loadSherpaOnnx, parseChunkEvent, parseDaemonConfig, parseLimit, parseTranscriptDate, parseWhisperJson, planService, pruneExpiredRawAudio, readPidFile, readPidRecord, removePidFile, removePidFileIfOwner, renderLaunchAgent, renderSystemdUnit, resolveCaptureAudioToken, resolveHelperBinary, resolveModelPath, runCapture, runWhisperCli, serializeDaemonConfig, sileroVadConfig, startDaemon, superviseReplay, tokensMatch, transcribeWithWhisper, uninstallService, wearableConnectorRegistration, whisperModelUrl, wordJaccard, writePidFile };
|