@gotcos/glasses-server 6.45.3 → 6.45.4

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.
@@ -0,0 +1,752 @@
1
+ // Held voices, grouped by who they sound like — across every meeting in the window.
2
+ //
3
+ // `ext-audio/<sessionId>/ext_chunk<N>_<ts>.wav` is one unidentified chunk. The
4
+ // Add-a-voice panel listed those by SESSION, which is the wrong grain twice
5
+ // over: a session with five strangers in it is not one voice, and the same
6
+ // stranger across four meetings is not four voices. Miles, 2026-09-12: "group
7
+ // those samples together… clump users together so that we're not constantly
8
+ // having to train. Realistically, people are only talking to up to a thousand
9
+ // different people at a time, so we should see many more groupings than
10
+ // individual exts that are unique." And: "bad random artifacts… ability to
11
+ // throw out those samples."
12
+ //
13
+ // So this module answers three questions over the whole retention window:
14
+ //
15
+ // groups which held samples are ONE voice (mutually coherent at the
16
+ // identifier's own floor, carved cluster by cluster out of one
17
+ // pairwise matrix — the same rule that guards profile corrections)
18
+ // loose which samples cohere with nothing — the random artifacts
19
+ // sounds like the enrolled profile that VOUCHES for a group: two or more of
20
+ // its samples above the floor, one of them from a source that may
21
+ // vouch (`vouchesForIdentity`), scored on the group's seed sample
22
+ // against the profile's second-best — so one polluted sample cannot
23
+ // vouch alone. That is the self-healing loop: a voice enrolled once,
24
+ // in one room, misses in the next room; its misses land here as a
25
+ // group that "may be X"; one confirmation appends them.
26
+ //
27
+ // WHERE THE VECTORS COME FROM. `chunk-embedding-store.ts` has banked every
28
+ // chunk's embedding — 'Ext' included — for 14 days since 6.21.15, and the ext
29
+ // wav and the banked row share the chunk index (verified: both writes use the
30
+ // same buffer in one call). So the ordinary case costs no audio decode at all.
31
+ // A sample without a banked row is decoded inside a time budget and cached in
32
+ // its own directory (the 72-hour purge reads the mtime of the first entry in a
33
+ // session folder, so nothing new may live there), and finished by a background
34
+ // sweep. The banked row outlives a wav that is named or discarded: it belongs
35
+ // to the meeting's corrections, not to this panel.
36
+ //
37
+ // WHAT THE REAL STORE TAUGHT (2026-09-12, a copy of Miles's data): best-of let
38
+ // one polluted sample vouch (0.894 against one of 40 samples, the other 39 at a
39
+ // median of 0.098); every "high" match was carried by `ext-retroactive` samples
40
+ // alone, a whole-session bulk enrol that had written one household voice into
41
+ // two people's profiles; 49 exact-duplicate chunk pairs from recordings started
42
+ // twice 12 ms apart made twelve perfect-coherence "voices"; and a centroid
43
+ // scores higher than any single sample, so the auto-enrol bar only means what
44
+ // it says on a real sample.
45
+
46
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
47
+ import { join, resolve } from 'node:path'
48
+ import { dataPath } from './data-dir.js'
49
+ import { extAudioChunkPath, listExtAudioChunks } from './meeting-audio-archive.js'
50
+ import { EXPECTED_EMBEDDING_DIM, decodeEmbedding, encodeEmbedding, readChunkEmbeddings } from './chunk-embedding-store.js'
51
+ import {
52
+ AUTO_ENROLL_THRESHOLD, enrollEmbedding, extractEmbedding, isEmbeddingAvailable, rawCosineSimilarity, readVoiceProfiles,
53
+ type VoiceProfile,
54
+ } from './speaker-embeddings.js'
55
+ import { vouchesForIdentity } from './embedding-eviction.js'
56
+ import { getOwnerSpeakerLabel } from './profile.js'
57
+ import {
58
+ MAX_ENROL_PER_CORRECTION,
59
+ VOICE_COHERENCE_FLOOR,
60
+ dominantCoherentClusterFromMatrix,
61
+ greedyDiversitySelect,
62
+ pairwiseSimilarityMatrix,
63
+ } from './voice-enrolment-selection.js'
64
+
65
+ /** Where decoded-on-demand vectors are kept. One JSON file per held session. */
66
+ export const HELD_EMBEDDING_CACHE_DIR = 'held-voice-embeddings'
67
+
68
+ /** A group whose SEED sample clears this against a profile's second-best sample
69
+ * is "high": the bar `autoEnroll` itself enrols at (0.88), applied at the same
70
+ * grain — one sample against a profile — never to a centroid, which scores
71
+ * higher than any of its samples. */
72
+ export const HELD_GROUP_HIGH_CONFIDENCE = AUTO_ENROLL_THRESHOLD
73
+
74
+ /** Below the identifier's own accept floor, no name is suggested at all.
75
+ * Speaker identity is a suggestion, never an assertion; under the floor the
76
+ * honest word is "unidentified". */
77
+ export const HELD_GROUP_SUGGESTION_FLOOR = VOICE_COHERENCE_FLOOR
78
+
79
+ /** A profile vouches only when at least this many of ITS samples clear the
80
+ * floor, and the score is the SECOND-best of them. */
81
+ export const HELD_SUGGESTION_MIN_SUPPORT = 2
82
+
83
+ /** Two held chunks this alike are the same recording banked twice, not two
84
+ * samples of a voice. They fold into one before grouping and before enrolment. */
85
+ export const HELD_DUPLICATE_SIMILARITY = 0.999
86
+
87
+ /** How long one listing may spend decoding audio for samples the bank does not
88
+ * hold. Past this they are `pending`, finished by the sweep. */
89
+ export const HELD_EXTRACTION_BUDGET_MS = 1_000
90
+
91
+ /** How long a naming request may spend decoding. A sample past this is
92
+ * `notReady` — untouched, listed for the next attempt — rather than a blocked
93
+ * event loop: one decode is ~126 ms on this Mac, so an unbudgeted request of
94
+ * a few hundred samples would stall live transcription for a minute. */
95
+ export const HELD_ENROLL_DECODE_BUDGET_MS = 2_000
96
+
97
+ /** A naming or discard request names at most this many samples. The live
98
+ * window measured 906 held chunks over 32 sessions (2026-09-12); forty per
99
+ * session over a hundred sessions is the honest ceiling. Guards a runaway
100
+ * client, not a product limit. */
101
+ export const MAX_HELD_MEMBERS_PER_REQUEST = 4_000
102
+
103
+ /** The listing is memoised this long while nothing held has changed. Control
104
+ * reloads it with every sessions refresh, and the matrix is synchronous. */
105
+ export const HELD_LISTING_MEMO_MS = 15_000
106
+
107
+ /** The provenance stamped on a profile sample that came from a held group:
108
+ * `ext-group:<sessionId>`, so the meeting's "not in this meeting" retraction
109
+ * can find it. */
110
+ export const HELD_GROUP_SOURCE = 'ext-group'
111
+
112
+ export interface HeldSampleRef {
113
+ sessionId: string
114
+ chunkIndex: number
115
+ }
116
+
117
+ export interface HeldSample extends HeldSampleRef {
118
+ embedding: Float32Array
119
+ }
120
+
121
+ export type HeldSuggestionTier = 'high' | 'likely'
122
+
123
+ export interface HeldGroupSuggestion {
124
+ name: string
125
+ /** Cosine of the group's seed sample against the profile's SECOND-best sample
126
+ * (its only sample, for a one-sample profile). Never the single best. */
127
+ similarity: number
128
+ tier: HeldSuggestionTier
129
+ /** How many of the profile's samples clear the floor, out of all of them. */
130
+ agreeing: number
131
+ of: number
132
+ /** The provenance of the strongest agreeing sample that may vouch. */
133
+ anchor: string
134
+ }
135
+
136
+ export interface HeldVoiceGroup {
137
+ /** The first member's key. Stable while that member is held. */
138
+ id: string
139
+ /** Every wav, duplicates included. Sorted by session then chunk. */
140
+ members: HeldSampleRef[]
141
+ sessions: string[]
142
+ /** Wavs on disk — what naming or discarding consumes. */
143
+ sampleCount: number
144
+ /** Samples after exact duplicates fold. A group needs two of these. */
145
+ distinctCount: number
146
+ /** Mean pairwise cosine among the distinct samples. Always at or above the floor. */
147
+ coherence: number
148
+ /** The member closest to everyone else — the one to play first, and the one scored. */
149
+ seed: HeldSampleRef
150
+ suggestion: HeldGroupSuggestion | null
151
+ }
152
+
153
+ export interface HeldVoiceGroupsResult {
154
+ groups: HeldVoiceGroup[]
155
+ loose: HeldSampleRef[]
156
+ sessions: number
157
+ /** Wavs on disk. */
158
+ samples: number
159
+ /** Samples that have a vector and took part in grouping. */
160
+ embedded: number
161
+ /** Samples still waiting for a decode — or, with the speaker model not
162
+ * loaded, waiting for it. */
163
+ pending: number
164
+ /** Samples whose wav can never be turned into a vector. */
165
+ unusable: number
166
+ /** Whether the speaker model is loaded on this Mac. Without it, nothing
167
+ * outside the bank can be grouped and nothing can be enrolled. */
168
+ speakerModel: boolean
169
+ generatedAt: string
170
+ }
171
+
172
+ export interface CollectedHeldSamples {
173
+ samples: HeldSample[]
174
+ pending: HeldSampleRef[]
175
+ unusable: HeldSampleRef[]
176
+ sessions: number
177
+ totalChunks: number
178
+ /** Vectors decoded from audio during THIS call. */
179
+ decoded: number
180
+ }
181
+
182
+ export class HeldGroupError extends Error {
183
+ constructor(readonly status: number, readonly reason: string, message: string, readonly details: Record<string, unknown> = {}) {
184
+ super(message)
185
+ this.name = 'HeldGroupError'
186
+ }
187
+ }
188
+
189
+ // ── Paths ──────────────────────────────────────────────────────────────────
190
+
191
+ /** The shape the chunk store and the meeting store accept. Session ids reach a
192
+ * filesystem path on every side; a looser validator than the writer's would
193
+ * only admit ids nothing else can find. */
194
+ const SESSION_ID_SHAPE = /^[A-Za-z0-9:_-]{3,96}$/
195
+
196
+ function normalizeSessionId(sessionId: string): string { return sessionId.replace(/:/g, '_') }
197
+
198
+ function extAudioRoot(): string { return dataPath('ext-audio') }
199
+
200
+ /** Held session directory names: the listing's `sessionId` values. */
201
+ export function heldSessionIds(): string[] {
202
+ const root = extAudioRoot()
203
+ if (!existsSync(root)) return []
204
+ const out: string[] = []
205
+ for (const d of readdirSync(root, { withFileTypes: true })) {
206
+ if (!d.isDirectory()) continue
207
+ if (!SESSION_ID_SHAPE.test(d.name)) continue
208
+ if (listExtAudioChunks(d.name).length === 0) continue
209
+ out.push(d.name)
210
+ }
211
+ return out.sort()
212
+ }
213
+
214
+ function cachePath(sessionId: string): string | null {
215
+ if (!SESSION_ID_SHAPE.test(sessionId)) return null
216
+ const dir = dataPath(HELD_EMBEDDING_CACHE_DIR)
217
+ const path = join(dir, `${normalizeSessionId(sessionId)}.json`)
218
+ return resolve(path).startsWith(resolve(dir) + '/') ? path : null
219
+ }
220
+
221
+ function readCache(sessionId: string): Record<string, string> {
222
+ const path = cachePath(sessionId)
223
+ if (!path || !existsSync(path)) return {}
224
+ try {
225
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
226
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
227
+ const out: Record<string, string> = {}
228
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) if (typeof v === 'string') out[k] = v
229
+ return out
230
+ } catch {
231
+ return {}
232
+ }
233
+ }
234
+
235
+ function writeCache(sessionId: string, cache: Record<string, string>): void {
236
+ const path = cachePath(sessionId)
237
+ if (!path) return
238
+ try {
239
+ if (Object.keys(cache).length === 0) {
240
+ if (existsSync(path)) unlinkSync(path)
241
+ return
242
+ }
243
+ mkdirSync(dataPath(HELD_EMBEDDING_CACHE_DIR), { recursive: true, mode: 0o700 })
244
+ const tmp = `${path}.tmp`
245
+ writeFileSync(tmp, JSON.stringify(cache), { mode: 0o600 })
246
+ renameSync(tmp, path)
247
+ } catch (err) {
248
+ console.warn(`[held-voice] cache write failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`)
249
+ }
250
+ }
251
+
252
+ /** Drop cache files for sessions the 72-hour purge has already removed, and
253
+ * any temp file a crash left behind. */
254
+ function pruneStaleCaches(liveSessionIds: Set<string>): void {
255
+ const dir = dataPath(HELD_EMBEDDING_CACHE_DIR)
256
+ if (!existsSync(dir)) return
257
+ for (const f of readdirSync(dir)) {
258
+ if (f.endsWith('.json.tmp')) { try { unlinkSync(join(dir, f)) } catch {} continue }
259
+ if (!f.endsWith('.json')) continue
260
+ if (liveSessionIds.has(f.slice(0, -'.json'.length))) continue
261
+ try { unlinkSync(join(dir, f)) } catch {}
262
+ }
263
+ }
264
+
265
+ // ── Samples ────────────────────────────────────────────────────────────────
266
+
267
+ export function sampleKey(ref: HeldSampleRef): string { return `${ref.sessionId}#${ref.chunkIndex}` }
268
+
269
+ function compareRefs(a: HeldSampleRef, b: HeldSampleRef): number {
270
+ return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : a.chunkIndex - b.chunkIndex
271
+ }
272
+
273
+ export interface CollectOptions {
274
+ /** Milliseconds of audio decoding allowed. 0 decodes nothing; Infinity decodes all. */
275
+ budgetMs?: number
276
+ /** Restrict to these sessions and chunk indices. */
277
+ only?: Map<string, Set<number>>
278
+ now?: () => number
279
+ }
280
+
281
+ /**
282
+ * Every held sample that has a vector, plus the ones that do not yet.
283
+ *
284
+ * Bank first, cache second, audio third — audio only inside the budget and
285
+ * only with the speaker model loaded. Without the model every unbanked sample
286
+ * is `pending`: nothing can decode it now, and the sweep does not run.
287
+ */
288
+ export function collectHeldSamples(opts: CollectOptions = {}): CollectedHeldSamples {
289
+ const budgetMs = opts.budgetMs ?? HELD_EXTRACTION_BUDGET_MS
290
+ const now = opts.now ?? (() => Date.now())
291
+ const deadline = now() + budgetMs
292
+ const modelReady = isEmbeddingAvailable()
293
+ const sessionIds = opts.only ? [...opts.only.keys()].filter(id => SESSION_ID_SHAPE.test(id)).sort() : heldSessionIds()
294
+ const samples: HeldSample[] = []
295
+ const pending: HeldSampleRef[] = []
296
+ const unusable: HeldSampleRef[] = []
297
+ let totalChunks = 0
298
+ let decoded = 0
299
+
300
+ for (const sessionId of sessionIds) {
301
+ const wanted = opts.only?.get(sessionId)
302
+ const indices = listExtAudioChunks(sessionId).filter(i => !wanted || wanted.has(i))
303
+ if (indices.length === 0) continue
304
+ totalChunks += indices.length
305
+
306
+ const banked = new Map<number, Float32Array>()
307
+ for (const row of readChunkEmbeddings(sessionId).rows) banked.set(row.i, row.embedding)
308
+ const cache = readCache(sessionId)
309
+ let cacheDirty = false
310
+
311
+ for (const chunkIndex of indices) {
312
+ const ref = { sessionId, chunkIndex }
313
+ const fromBank = banked.get(chunkIndex)
314
+ if (fromBank) { samples.push({ ...ref, embedding: fromBank }); continue }
315
+ const fromCache = cache[String(chunkIndex)]
316
+ if (fromCache !== undefined) {
317
+ const vector = fromCache === '' ? null : decodeEmbedding(fromCache)
318
+ if (vector && vector.length === EXPECTED_EMBEDDING_DIM) { samples.push({ ...ref, embedding: vector }); continue }
319
+ // '' is a remembered refusal: the wav decoded to the wrong shape once
320
+ // and will again. Anything else that fails to decode is a corrupt
321
+ // entry, re-decoded below.
322
+ if (fromCache === '') { unusable.push(ref); continue }
323
+ }
324
+ if (!modelReady || now() >= deadline) { pending.push(ref); continue }
325
+ const wav = extAudioChunkPath(sessionId, chunkIndex)
326
+ let vector: Float32Array | null = null
327
+ try { vector = wav ? extractEmbedding(readFileSync(wav)) : null } catch { vector = null }
328
+ decoded++
329
+ if (vector && vector.length === EXPECTED_EMBEDDING_DIM) {
330
+ cache[String(chunkIndex)] = encodeEmbedding(vector)
331
+ cacheDirty = true
332
+ samples.push({ ...ref, embedding: vector })
333
+ } else if (vector) {
334
+ cache[String(chunkIndex)] = ''
335
+ cacheDirty = true
336
+ unusable.push(ref)
337
+ } else {
338
+ // The model is loaded and still returned nothing: the audio itself is
339
+ // the problem (unreadable wav). Nothing to retry.
340
+ cache[String(chunkIndex)] = ''
341
+ cacheDirty = true
342
+ unusable.push(ref)
343
+ }
344
+ }
345
+ if (cacheDirty) writeCache(sessionId, cache)
346
+ }
347
+ if (!opts.only) pruneStaleCaches(new Set(sessionIds.map(normalizeSessionId)))
348
+ return { samples, pending, unusable, sessions: sessionIds.length, totalChunks, decoded }
349
+ }
350
+
351
+ // ── Grouping ───────────────────────────────────────────────────────────────
352
+
353
+ /** Exact duplicates fold onto the lowest index; `copies` lists every index
354
+ * each representative stands for. */
355
+ export function foldDuplicates(sim: number[][], count: number): { reps: number[]; copies: Map<number, number[]> } {
356
+ const copies = new Map<number, number[]>()
357
+ const rep = Array.from({ length: count }, (_, i) => i)
358
+ for (let i = 0; i < count; i++) {
359
+ if (rep[i] !== i) continue
360
+ copies.set(i, [i])
361
+ for (let j = i + 1; j < count; j++) {
362
+ if (rep[j] === j && sim[i][j] >= HELD_DUPLICATE_SIMILARITY) { rep[j] = i; copies.get(i)!.push(j) }
363
+ }
364
+ }
365
+ return { reps: [...copies.keys()], copies }
366
+ }
367
+
368
+ export interface SuggestOptions {
369
+ /** The wearer's own label. Never offered: one click would write a stranger
370
+ * into the profile that drives owner detection. */
371
+ ownerLabel?: string
372
+ }
373
+
374
+ /**
375
+ * The enrolled profile that VOUCHES for a sample.
376
+ *
377
+ * Every profile is scored: its samples' cosines against `embedding`, sorted.
378
+ * With two or more samples a profile is a candidate only when at least
379
+ * `HELD_SUGGESTION_MIN_SUPPORT` of them clear the floor, and its score is the
380
+ * second-best; a one-sample profile is a candidate at its one score and can
381
+ * only ever be "likely". The best-scoring candidate wins — and if that winner
382
+ * has no agreeing sample from a source that may vouch (`vouchesForIdentity`),
383
+ * the answer is NO suggestion, not the runner-up: the runner-up is a different
384
+ * person, and the voice most likely belongs to the profile that cannot vouch.
385
+ */
386
+ export function suggestProfile(embedding: Float32Array, profiles: VoiceProfile[], opts: SuggestOptions = {}): HeldGroupSuggestion | null {
387
+ type Candidate = { name: string; score: number; tier: HeldSuggestionTier; agreeing: number; of: number; anchor: string | null }
388
+ let best: Candidate | null = null
389
+ for (const profile of profiles) {
390
+ if (opts.ownerLabel && profile.name === opts.ownerLabel) continue
391
+ const rows: Array<{ sim: number; source: string }> = []
392
+ profile.embeddings.forEach((candidate, i) => {
393
+ if (candidate.length !== embedding.length) return
394
+ rows.push({ sim: rawCosineSimilarity(embedding, new Float32Array(candidate)), source: profile.sources?.[i] ?? 'unknown' })
395
+ })
396
+ if (rows.length === 0) continue
397
+ rows.sort((a, b) => b.sim - a.sim)
398
+ const agreeing = rows.filter(r => r.sim >= HELD_GROUP_SUGGESTION_FLOOR)
399
+ let score: number
400
+ let tier: HeldSuggestionTier
401
+ if (rows.length >= 2) {
402
+ if (agreeing.length < HELD_SUGGESTION_MIN_SUPPORT) continue
403
+ score = rows[1].sim
404
+ tier = score >= HELD_GROUP_HIGH_CONFIDENCE ? 'high' : 'likely'
405
+ } else {
406
+ if (rows[0].sim < HELD_GROUP_SUGGESTION_FLOOR) continue
407
+ score = rows[0].sim
408
+ tier = 'likely'
409
+ }
410
+ const anchored = agreeing.find(r => vouchesForIdentity(r.source))
411
+ const candidate: Candidate = {
412
+ name: profile.name, score, tier, agreeing: agreeing.length, of: profile.embeddings.length,
413
+ anchor: anchored ? anchored.source.split(':')[0] : null,
414
+ }
415
+ if (!best || candidate.score > best.score) best = candidate
416
+ }
417
+ if (!best || best.anchor === null) return null
418
+ return { name: best.name, similarity: Number(best.score.toFixed(4)), tier: best.tier, agreeing: best.agreeing, of: best.of, anchor: best.anchor }
419
+ }
420
+
421
+ export interface BuildOptions extends SuggestOptions {
422
+ floor?: number
423
+ }
424
+
425
+ /**
426
+ * Carve the held samples into voices.
427
+ *
428
+ * One pairwise matrix. Exact duplicates fold into one distinct sample first, or
429
+ * every doubled recording would read as a two-sample voice of perfect
430
+ * coherence. Then repeatedly take the dominant mutually coherent cluster out of
431
+ * what remains until nothing coheres. Whatever is left is loose. A group is at
432
+ * least TWO distinct samples: a lone sample has no evidence of being a voice
433
+ * rather than a noise, and it is listed loose so the reviewer can throw it out
434
+ * or, hearing a real person in it, name it on its own. Members and loose carry
435
+ * EVERY wav, duplicates included, so naming or discarding consumes the copies.
436
+ */
437
+ export function buildHeldVoiceGroups(samples: HeldSample[], profiles: VoiceProfile[], opts: BuildOptions = {}): { groups: HeldVoiceGroup[]; loose: HeldSampleRef[] } {
438
+ const floor = opts.floor ?? VOICE_COHERENCE_FLOOR
439
+ const groups: HeldVoiceGroup[] = []
440
+ if (samples.length === 0) return { groups, loose: [] }
441
+ const sim = pairwiseSimilarityMatrix(samples.map(s => s.embedding))
442
+ const { reps, copies } = foldDuplicates(sim, samples.length)
443
+ const refsOf = (idx: number[]): HeldSampleRef[] =>
444
+ idx.flatMap(i => copies.get(i)!).map(i => ({ sessionId: samples[i].sessionId, chunkIndex: samples[i].chunkIndex })).sort(compareRefs)
445
+ let active = reps
446
+ for (;;) {
447
+ // ONE guard, load-bearing on its own: a lone candidate comes back from the
448
+ // matrix search as its own cluster of one, and a group needs two.
449
+ const cluster = dominantCoherentClusterFromMatrix(sim, active, floor)
450
+ if (cluster.members.length < 2) break
451
+ const memberIdx = cluster.members
452
+ let sum = 0, pairs = 0
453
+ for (let a = 0; a < memberIdx.length; a++) {
454
+ for (let b = a + 1; b < memberIdx.length; b++) { sum += sim[memberIdx[a]][memberIdx[b]]; pairs++ }
455
+ }
456
+ const members = refsOf(memberIdx)
457
+ const seedSample = samples[cluster.seed]
458
+ groups.push({
459
+ id: sampleKey(members[0]),
460
+ members,
461
+ sessions: [...new Set(members.map(m => m.sessionId))].sort(),
462
+ sampleCount: members.length,
463
+ distinctCount: memberIdx.length,
464
+ coherence: Number((pairs > 0 ? sum / pairs : 1).toFixed(4)),
465
+ seed: { sessionId: seedSample.sessionId, chunkIndex: seedSample.chunkIndex },
466
+ suggestion: suggestProfile(seedSample.embedding, profiles, opts),
467
+ })
468
+ const taken = new Set(memberIdx)
469
+ active = active.filter(i => !taken.has(i))
470
+ }
471
+ groups.sort((a, b) => b.sampleCount - a.sampleCount || b.coherence - a.coherence || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
472
+ return { groups, loose: refsOf(active) }
473
+ }
474
+
475
+ // ── Listing ────────────────────────────────────────────────────────────────
476
+
477
+ let listingMemo: { fingerprint: string; at: number; result: HeldVoiceGroupsResult } | null = null
478
+
479
+ /** What the listing depends on: which wavs are held, and the profile store. */
480
+ function listingFingerprint(sessionIds: string[]): string {
481
+ let profilesStamp = '0'
482
+ try { profilesStamp = String(statSync(dataPath('voice-profiles.json')).mtimeMs) } catch {}
483
+ return `${isEmbeddingAvailable() ? 'm' : '-'}|${getOwnerSpeakerLabel()}|${profilesStamp}|` + sessionIds.map(id => `${id}:${listExtAudioChunks(id).join(',')}`).join(';')
484
+ }
485
+
486
+ export function __resetHeldListingMemoForTests(): void { listingMemo = null }
487
+
488
+ /** The listing. Decodes within the budget; anything past it is `pending` and
489
+ * the background sweep finishes it for the next call. Memoised briefly while
490
+ * nothing held has changed. */
491
+ export function heldVoiceGroups(opts: { budgetMs?: number; now?: () => number } = {}): HeldVoiceGroupsResult {
492
+ const now = opts.now ?? (() => Date.now())
493
+ const sessionIds = heldSessionIds()
494
+ const fingerprint = listingFingerprint(sessionIds)
495
+ if (listingMemo && listingMemo.fingerprint === fingerprint && now() - listingMemo.at < HELD_LISTING_MEMO_MS && listingMemo.result.pending === 0) {
496
+ return listingMemo.result
497
+ }
498
+ const collected = collectHeldSamples({ budgetMs: opts.budgetMs, now })
499
+ const { groups, loose } = buildHeldVoiceGroups(collected.samples, readVoiceProfiles().profiles, { ownerLabel: getOwnerSpeakerLabel() })
500
+ if (collected.pending.length > 0) scheduleHeldEmbeddingSweep()
501
+ const result: HeldVoiceGroupsResult = {
502
+ groups,
503
+ loose,
504
+ sessions: collected.sessions,
505
+ samples: collected.totalChunks,
506
+ embedded: collected.samples.length,
507
+ pending: collected.pending.length,
508
+ unusable: collected.unusable.length,
509
+ speakerModel: isEmbeddingAvailable(),
510
+ generatedAt: new Date().toISOString(),
511
+ }
512
+ listingMemo = { fingerprint, at: now(), result }
513
+ return result
514
+ }
515
+
516
+ // ── Background decode ──────────────────────────────────────────────────────
517
+
518
+ export const SWEEP_SLICE_MS = 250
519
+ export const SWEEP_GAP_MS = 50
520
+ let sweepTimer: NodeJS.Timeout | null = null
521
+ let sweepLastPending: number | null = null
522
+
523
+ /** One slice of the sweep. `progressed` is false when the pending count did
524
+ * not fall — the scheduler stops rather than spin. */
525
+ export function runHeldEmbeddingSweepTick(budgetMs: number = SWEEP_SLICE_MS): { pending: number; progressed: boolean } {
526
+ let pending = 0
527
+ try { pending = collectHeldSamples({ budgetMs }).pending.length } catch { pending = 0 }
528
+ const progressed = sweepLastPending === null || pending < sweepLastPending
529
+ sweepLastPending = pending
530
+ if (pending === 0) listingMemo = null
531
+ return { pending, progressed }
532
+ }
533
+
534
+ /**
535
+ * Finish the decodes a listing could not afford, a slice at a time, off the
536
+ * request path. Single-flight; each slice persists through the cache; stops
537
+ * when nothing is pending, when a slice made no progress, or when the speaker
538
+ * model is not loaded (nothing could decode).
539
+ */
540
+ export function scheduleHeldEmbeddingSweep(): void {
541
+ if (sweepTimer || !isEmbeddingAvailable()) return
542
+ sweepLastPending = null
543
+ const tick = () => {
544
+ sweepTimer = null
545
+ const { pending, progressed } = runHeldEmbeddingSweepTick()
546
+ if (pending > 0 && progressed && isEmbeddingAvailable()) {
547
+ sweepTimer = setTimeout(tick, SWEEP_GAP_MS)
548
+ sweepTimer.unref()
549
+ }
550
+ }
551
+ sweepTimer = setTimeout(tick, SWEEP_GAP_MS)
552
+ sweepTimer.unref()
553
+ }
554
+
555
+ export function __heldSweepArmedForTests(): boolean { return sweepTimer !== null }
556
+
557
+ export function __resetHeldEmbeddingSweepForTests(): void {
558
+ if (sweepTimer) clearTimeout(sweepTimer)
559
+ sweepTimer = null
560
+ sweepLastPending = null
561
+ listingMemo = null
562
+ }
563
+
564
+ // ── Corrections ────────────────────────────────────────────────────────────
565
+
566
+ /** Validate a request body's member list into refs: de-duplicated, normalised,
567
+ * then capped. Throws on shape errors. */
568
+ export function parseHeldMembers(raw: unknown): HeldSampleRef[] {
569
+ if (!Array.isArray(raw) || raw.length === 0) {
570
+ throw new HeldGroupError(400, 'invalid_members', 'members must be a non-empty array of { sessionId, chunkIndex }')
571
+ }
572
+ const seen = new Set<string>()
573
+ const refs: HeldSampleRef[] = []
574
+ for (const item of raw) {
575
+ const rawSession = (item as { sessionId?: unknown })?.sessionId
576
+ const rawChunk = (item as { chunkIndex?: unknown })?.chunkIndex
577
+ const sessionId = typeof rawSession === 'string' ? normalizeSessionId(rawSession) : ''
578
+ const chunkIndex = typeof rawChunk === 'number' ? rawChunk
579
+ : typeof rawChunk === 'string' && /^\d{1,9}$/.test(rawChunk) ? Number(rawChunk) : NaN
580
+ if (!SESSION_ID_SHAPE.test(sessionId) || !Number.isInteger(chunkIndex) || chunkIndex < 0) {
581
+ throw new HeldGroupError(400, 'invalid_members', 'each member needs a sessionId and a non-negative integer chunkIndex')
582
+ }
583
+ const ref = { sessionId, chunkIndex }
584
+ if (seen.has(sampleKey(ref))) continue
585
+ seen.add(sampleKey(ref))
586
+ refs.push(ref)
587
+ }
588
+ if (refs.length > MAX_HELD_MEMBERS_PER_REQUEST) {
589
+ throw new HeldGroupError(400, 'too_many_members', `members is capped at ${MAX_HELD_MEMBERS_PER_REQUEST} distinct samples per request`, { distinct: refs.length })
590
+ }
591
+ return refs.sort(compareRefs)
592
+ }
593
+
594
+ function onlyMap(refs: HeldSampleRef[]): Map<string, Set<number>> {
595
+ const only = new Map<string, Set<number>>()
596
+ for (const r of refs) {
597
+ const set = only.get(r.sessionId) ?? new Set<number>()
598
+ set.add(r.chunkIndex)
599
+ only.set(r.sessionId, set)
600
+ }
601
+ return only
602
+ }
603
+
604
+ /** Which of these refs are still held on disk. */
605
+ export function previewDiscard(refs: HeldSampleRef[]): { present: HeldSampleRef[]; missing: HeldSampleRef[] } {
606
+ const present: HeldSampleRef[] = []
607
+ const missing: HeldSampleRef[] = []
608
+ for (const ref of refs) (extAudioChunkPath(ref.sessionId, ref.chunkIndex) ? present : missing).push(ref)
609
+ return { present, missing }
610
+ }
611
+
612
+ /** Remove the wavs behind these refs. Returns what was removed and what was not there. */
613
+ export function discardHeldSamples(refs: HeldSampleRef[]): { removed: HeldSampleRef[]; missing: HeldSampleRef[] } {
614
+ const removed: HeldSampleRef[] = []
615
+ const missing: HeldSampleRef[] = []
616
+ const touched = new Map<string, Record<string, string>>()
617
+ for (const ref of refs) {
618
+ const cache = touched.get(ref.sessionId) ?? readCache(ref.sessionId)
619
+ delete cache[String(ref.chunkIndex)]
620
+ touched.set(ref.sessionId, cache)
621
+ const wav = extAudioChunkPath(ref.sessionId, ref.chunkIndex)
622
+ if (!wav) { missing.push(ref); continue }
623
+ try { unlinkSync(wav); removed.push(ref) } catch { missing.push(ref) }
624
+ }
625
+ for (const [sessionId, cache] of touched) writeCache(sessionId, cache)
626
+ listingMemo = null
627
+ console.log(`[held-voice] discard: removed=${removed.length} missing=${missing.length} sessions=[${[...new Set(refs.map(r => r.sessionId))].join(',')}]`)
628
+ return { removed, missing }
629
+ }
630
+
631
+ export interface EnrollHeldGroupPlan {
632
+ speaker: string
633
+ /** True when no profile of that name existed before. */
634
+ created: boolean
635
+ /** Distinct samples the request named. */
636
+ submitted: number
637
+ /** Of those, the ones held on disk with a usable vector. */
638
+ resolved: number
639
+ /** Resolved samples after exact duplicates fold. */
640
+ distinct: number
641
+ missing: HeldSampleRef[]
642
+ /** Held but not yet decoded within the request budget: untouched, listed for
643
+ * the next attempt. The sweep is working on them. */
644
+ notReady: HeldSampleRef[]
645
+ /** Wavs in the mutually coherent core that was treated as this one voice. */
646
+ coherent: number
647
+ /** Submitted samples that did NOT cohere with the core. Left on disk, untouched. */
648
+ leftBehind: HeldSampleRef[]
649
+ /** Diverse subset of the distinct core to be written to the profile. */
650
+ selected: number
651
+ sessions: string[]
652
+ profileEmbeddings: number
653
+ /** Whether the speaker model is loaded: false means a confirmed request will
654
+ * be refused (503) even though this preview could be computed. */
655
+ speakerModel: boolean
656
+ }
657
+
658
+ export interface EnrollHeldGroupResult extends EnrollHeldGroupPlan {
659
+ dryRun: boolean
660
+ enrolled: number
661
+ /** Wavs removed: the whole core, selected or not, because all of it is now a known voice. */
662
+ deleted: number
663
+ }
664
+
665
+ /**
666
+ * Name a set of held samples as one person.
667
+ *
668
+ * NEVER cuts a corner on coherence: the submitted set is re-clustered here
669
+ * regardless of what the client believed, duplicates folded first, and only
670
+ * the mutually coherent core is written. A set that agrees on nothing is
671
+ * refused outright rather than enrolled as "whichever stranger came first". A
672
+ * single sample is allowed — naming sample by sample is exactly what a
673
+ * low-confidence voice needs — and a human hearing one chunk is the evidence.
674
+ *
675
+ * Deletes only the core's wavs, and only after at least one sample was
676
+ * written. With the speaker model not loaded nothing can be written, so the
677
+ * request is refused before it touches anything.
678
+ */
679
+ export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dryRun?: boolean } = {}): EnrollHeldGroupResult {
680
+ const dryRun = opts.dryRun === true
681
+ const collected = collectHeldSamples({ only: onlyMap(refs), budgetMs: HELD_ENROLL_DECODE_BUDGET_MS })
682
+ if (collected.pending.length > 0) scheduleHeldEmbeddingSweep()
683
+ const have = new Map(collected.samples.map(s => [sampleKey(s), s]))
684
+ const notReadyKeys = new Set(collected.pending.map(sampleKey))
685
+ const notReady = refs.filter(r => notReadyKeys.has(sampleKey(r)))
686
+ const missing = refs.filter(r => !have.has(sampleKey(r)) && !notReadyKeys.has(sampleKey(r)))
687
+ const resolved = refs.filter(r => have.has(sampleKey(r))).map(r => have.get(sampleKey(r))!)
688
+ if (resolved.length === 0) {
689
+ throw new HeldGroupError(404, 'no_samples', notReady.length > 0
690
+ ? 'Those samples are still being read; try again in a moment.'
691
+ : 'None of those samples are held any more, or none could be turned into a voiceprint.', {
692
+ missing, notReady, unusable: collected.unusable.length,
693
+ })
694
+ }
695
+
696
+ const sim = pairwiseSimilarityMatrix(resolved.map(s => s.embedding))
697
+ const { reps, copies } = foldDuplicates(sim, resolved.length)
698
+ const cluster = reps.length === 1 ? { members: reps, seed: reps[0] } : dominantCoherentClusterFromMatrix(sim, reps)
699
+ if (cluster.members.length === 0) {
700
+ throw new HeldGroupError(409, 'incoherent', 'Those samples do not sound like one person. Nothing was enrolled; name them separately or discard the odd ones out.', {
701
+ submitted: refs.length, resolved: resolved.length, distinct: reps.length,
702
+ })
703
+ }
704
+ const coreReps = cluster.members
705
+ const coreSet = new Set(coreReps)
706
+ const expand = (idx: number[]): HeldSample[] => idx.flatMap(i => copies.get(i)!).map(i => resolved[i])
707
+ const core = expand(coreReps)
708
+ const leftBehind = expand(reps.filter(i => !coreSet.has(i))).map(s => ({ sessionId: s.sessionId, chunkIndex: s.chunkIndex })).sort(compareRefs)
709
+
710
+ const existing = readVoiceProfiles().profiles.find(p => p.name === name)
711
+ // A primitive snapshot, taken BEFORE the loop: the store hands back its live
712
+ // `embeddings` array by reference, and enrolment appends to that same array
713
+ // until the first write invalidates the cache. Reading `.length` afterwards
714
+ // counted the new samples twice (caught by the route test, 2026-09-12).
715
+ const existingCount = existing?.embeddings.length ?? 0
716
+ const bySample = new Map<Float32Array, HeldSample>(coreReps.map(i => [resolved[i].embedding, resolved[i]]))
717
+ const selected = greedyDiversitySelect(coreReps.map(i => resolved[i].embedding), MAX_ENROL_PER_CORRECTION)
718
+ const plan: EnrollHeldGroupPlan = {
719
+ speaker: name,
720
+ created: !existing,
721
+ submitted: refs.length,
722
+ resolved: resolved.length,
723
+ distinct: reps.length,
724
+ missing,
725
+ notReady,
726
+ coherent: core.length,
727
+ leftBehind,
728
+ selected: selected.length,
729
+ sessions: [...new Set(core.map(s => s.sessionId))].sort(),
730
+ profileEmbeddings: existingCount,
731
+ speakerModel: isEmbeddingAvailable(),
732
+ }
733
+ // A dry run writes no profile and deletes no wav. It may still decode and
734
+ // cache vectors for the samples it was asked about, and arm the sweep.
735
+ if (dryRun) return { ...plan, dryRun: true, enrolled: 0, deleted: 0 }
736
+ if (!isEmbeddingAvailable()) {
737
+ throw new HeldGroupError(503, 'speaker_model_unavailable', 'The speaker model is not loaded on this Mac, so nothing can be enrolled. Nothing was changed.', { ...plan })
738
+ }
739
+
740
+ let enrolled = 0
741
+ for (const emb of selected) {
742
+ const sample = bySample.get(emb)
743
+ const source = sample ? `${HELD_GROUP_SOURCE}:${sample.sessionId}` : HELD_GROUP_SOURCE
744
+ if (enrollEmbedding(name, emb, source, true).success) enrolled++
745
+ }
746
+ if (enrolled === 0) {
747
+ throw new HeldGroupError(409, 'nothing_enrolled', `The profile store refused every sample for ${name}; nothing was deleted.`, { ...plan })
748
+ }
749
+ const { removed } = discardHeldSamples(core.map(s => ({ sessionId: s.sessionId, chunkIndex: s.chunkIndex })))
750
+ console.log(`[held-voice] enroll "${name}" (${existing ? 'appended' : 'created'}): submitted=${refs.length} resolved=${resolved.length} distinct=${reps.length} coherent=${core.length} selected=${selected.length} enrolled=${enrolled} deleted=${removed.length} leftBehind=${leftBehind.length} notReady=${notReady.length} sessions=[${plan.sessions.join(',')}]`)
751
+ return { ...plan, dryRun: false, enrolled, deleted: removed.length, profileEmbeddings: existingCount + enrolled }
752
+ }