@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.
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/server/lib/embedding-eviction.ts +19 -0
- package/server/lib/held-voice-groups.ts +752 -0
- package/server/lib/prompt-tail-guard.ts +257 -0
- package/server/lib/speaker-embeddings.ts +8 -1
- package/server/lib/training-audio-provenance.ts +1 -1
- package/server/lib/transcribe-audio.ts +9 -1
- package/server/lib/vad-silero.ts +8 -3
- package/server/lib/voice-enrolment-selection.ts +31 -5
- package/server/lib/whisper-local.ts +12 -6
- package/server/routes/prompt-drafts.ts +13 -2
- package/server/routes/transcribe-stream.ts +5 -3
- package/server/routes/voice.ts +117 -1
|
@@ -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
|
+
}
|