@gotcos/glasses-server 6.21.9 → 6.21.13

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.
@@ -37,6 +37,7 @@ import {
37
37
  } from '../lib/hallucination-filter.js'
38
38
  import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
39
39
  import { dataPath } from '../lib/data-dir.js'
40
+ import { ageHours, partitionExpiredAudio } from '../lib/audio-retention.js'
40
41
  import {
41
42
  countChunkWavs,
42
43
  purgeExpiredQuarantine,
@@ -118,6 +119,13 @@ function meetingTurboPreviewEnabled(): boolean {
118
119
  const AUDIO_SAVE_DIR = dataPath('training-audio')
119
120
  ensurePrivateDirectory(AUDIO_SAVE_DIR)
120
121
  const MAX_SAVED_CHUNKS_PER_SPEAKER = 30 // ~5 min of audio per speaker, cleaned after training
122
+ // Age bound. The count cap above is NOT a retention policy: a speaker who never
123
+ // gets trained keeps 30 WAVs of their voice indefinitely, and the only cleanup
124
+ // path was a manual /voice/train-g2 call. ext-audio has had a 72h sweep since it
125
+ // was introduced; this closes the same gap for training audio. Deliberately
126
+ // longer than ext-audio's window because these chunks are the raw material for
127
+ // deliberate enrollment, not opportunistic retroactive matching.
128
+ const TRAINING_AUDIO_TTL_MS = 14 * 24 * 60 * 60 * 1000 // 14 days
121
129
 
122
130
  // Unrecognized speaker audio: save Ext chunks for retroactive enrollment
123
131
  const EXT_AUDIO_DIR = dataPath('ext-audio')
@@ -742,6 +750,44 @@ setInterval(() => {
742
750
  }
743
751
  } catch {}
744
752
 
753
+ // Purge training-audio WAVs past the retention window. Per file, not per
754
+ // directory: chunks for one speaker accumulate over weeks, so an
755
+ // all-or-nothing directory check would either keep month-old audio alive
756
+ // because one chunk is fresh, or delete today's capture because the directory
757
+ // is old.
758
+ try {
759
+ if (existsSync(AUDIO_SAVE_DIR)) {
760
+ const now = Date.now()
761
+ for (const dir of readdirSync(AUDIO_SAVE_DIR)) {
762
+ const dirPath = resolve(AUDIO_SAVE_DIR, dir)
763
+ try {
764
+ if (!statSync(dirPath).isDirectory()) continue
765
+ const candidates = readdirSync(dirPath)
766
+ .filter(f => f.endsWith('.wav'))
767
+ .map(name => {
768
+ let mtimeMs = 0
769
+ try { mtimeMs = statSync(resolve(dirPath, name)).mtimeMs } catch {}
770
+ return { name, mtimeMs }
771
+ })
772
+ const { expired, retained } = partitionExpiredAudio(candidates, now, TRAINING_AUDIO_TTL_MS)
773
+ for (const file of expired) {
774
+ try { unlinkSync(resolve(dirPath, file.name)) } catch {}
775
+ }
776
+ if (expired.length > 0) {
777
+ const oldest = Math.min(...expired.map(f => f.mtimeMs))
778
+ console.log(
779
+ `[training-audio] Purged ${expired.length} expired chunk(s) for ${dir}`,
780
+ `(oldest ${ageHours(oldest, now)}h, ${retained.length} retained)`,
781
+ )
782
+ }
783
+ if (retained.length === 0 && readdirSync(dirPath).length === 0) {
784
+ try { rmSync(dirPath, { recursive: true, force: true }) } catch {}
785
+ }
786
+ } catch {}
787
+ }
788
+ }
789
+ } catch {}
790
+
745
791
  // Purge ext-audio dirs older than 72 hours
746
792
  try {
747
793
  if (existsSync(EXT_AUDIO_DIR)) {
@@ -4,15 +4,33 @@ import { Router } from 'express'
4
4
  import { errMsg } from '../lib/utils.js'
5
5
  import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
- import { fileURLToPath } from 'node:url'
8
- import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
7
+ import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
9
8
  import { statSync } from 'node:fs'
10
9
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
11
10
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
12
-
13
- const __dirname = fileURLToPath(new URL('.', import.meta.url))
14
- const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
15
- const EXT_AUDIO_DIR = resolve(__dirname, '..', 'data', 'ext-audio')
11
+ import { dataPath } from '../lib/data-dir.js'
12
+ import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
13
+
14
+ // These MUST match the writer in transcribe-stream.ts, which saves under
15
+ // dataPath(). They previously resolved relative to __dirname — i.e. inside the
16
+ // installed package generation, a directory the writer never touches and that
17
+ // every managed update replaces. Every reader below therefore reported zero
18
+ // speakers and zero sessions while real audio accumulated in the data home.
19
+ const AUDIO_SAVE_DIR = dataPath('training-audio')
20
+ const EXT_AUDIO_DIR = dataPath('ext-audio')
21
+ // Must match speaker-embeddings.ts, which appends every identification decision.
22
+ const CALIBRATION_LOG = dataPath('speaker-calibration.jsonl')
23
+
24
+ /** A speaker directory name is derived from a label by replacing spaces with
25
+ * underscores. Resolve back through basename so a crafted `speaker` value
26
+ * cannot escape the audio root. */
27
+ function speakerDirPath(root: string, speakerName: string): string | null {
28
+ const dirName = speakerName.trim().replace(/\s+/g, '_')
29
+ if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName.includes('..')) return null
30
+ const path = resolve(root, dirName)
31
+ if (!path.startsWith(resolve(root) + '/')) return null
32
+ return path
33
+ }
16
34
 
17
35
  export const voiceRouter = Router()
18
36
 
@@ -97,19 +115,73 @@ voiceRouter.get('/voice/training-status', async (_req, res) => {
97
115
  })
98
116
 
99
117
  // POST /api/voice/train-g2 — train from saved G2-mic audio chunks
100
- // These accumulate during meetings for speakers who need more embeddings
118
+ // These accumulate during meetings for speakers who need more embeddings.
119
+ //
120
+ // Body: { speaker?, confirmAllSpeakers?, dryRun?, maxPerSpeaker? }
121
+ //
122
+ // This endpoint permanently rewrites voice profiles AND deletes the source WAVs,
123
+ // so the unscoped form now requires an explicit confirmation. Two reasons, both
124
+ // load-bearing:
125
+ //
126
+ // 1. Until the reader path above was fixed it saw an empty directory, so a
127
+ // no-argument call was harmless. It is not harmless any more — it now reaches
128
+ // every accumulated speaker directory at once.
129
+ // 2. Enrolling N samples into a profile capped at 20 evicts the oldest sample N
130
+ // times. A 30-WAV directory would therefore discard EVERY pre-existing
131
+ // embedding for that speaker, replacing months of curated training with one
132
+ // meeting's audio. Diversity selection bounds the enrollment instead.
133
+ const DEFAULT_MAX_TRAIN_PER_SPEAKER = 10
134
+
101
135
  voiceRouter.post('/voice/train-g2', async (req, res) => {
102
136
  try {
103
137
  const targetSpeaker = req.body?.speaker as string | undefined
138
+ const confirmAll = req.body?.confirmAllSpeakers === true
139
+ const dryRun = req.body?.dryRun === true
140
+ const maxPerSpeaker = Number.isFinite(req.body?.maxPerSpeaker)
141
+ ? Math.max(1, Math.min(20, Number(req.body.maxPerSpeaker)))
142
+ : DEFAULT_MAX_TRAIN_PER_SPEAKER
143
+
104
144
  if (!existsSync(AUDIO_SAVE_DIR)) {
105
145
  return res.json({ trained: 0, speakers: [], message: 'No saved G2 audio yet' })
106
146
  }
107
147
 
108
- const speakerDirs = readdirSync(AUDIO_SAVE_DIR, { withFileTypes: true })
148
+ let speakerDirs = readdirSync(AUDIO_SAVE_DIR, { withFileTypes: true })
109
149
  .filter(d => d.isDirectory())
110
- .filter(d => !targetSpeaker || d.name === targetSpeaker.replace(/\s+/g, '_'))
111
150
 
112
- const results: Array<{ speaker: string; chunks: number; enrolled: number }> = []
151
+ if (targetSpeaker) {
152
+ const wanted = speakerDirPath(AUDIO_SAVE_DIR, targetSpeaker)
153
+ if (!wanted) return res.status(400).json({ error: 'invalid speaker name' })
154
+ speakerDirs = speakerDirs.filter(d => resolve(AUDIO_SAVE_DIR, d.name) === wanted)
155
+ if (speakerDirs.length === 0) {
156
+ return res.status(404).json({ error: `No saved G2 audio for "${targetSpeaker}"` })
157
+ }
158
+ } else if (!confirmAll && !dryRun) {
159
+ // Fail closed with the inventory, so the caller can see exactly what a
160
+ // confirmation would rewrite before granting it.
161
+ const pending = speakerDirs.map(d => {
162
+ const name = d.name.replace(/_/g, ' ')
163
+ let chunks = 0
164
+ try { chunks = readdirSync(resolve(AUDIO_SAVE_DIR, d.name)).filter(f => f.endsWith('.wav')).length } catch {}
165
+ return { speaker: name, chunks, currentEmbeddings: getEmbeddingCount(name) }
166
+ }).filter(s => s.chunks > 0)
167
+ return res.status(400).json({
168
+ error: 'confirmation required',
169
+ message: 'Training every speaker at once rewrites their profiles and deletes the source audio. '
170
+ + 'Pass { speaker } to scope it, { dryRun: true } to preview, or { confirmAllSpeakers: true } to proceed.',
171
+ wouldTrain: pending,
172
+ totalSpeakers: pending.length,
173
+ totalChunks: pending.reduce((sum, s) => sum + s.chunks, 0),
174
+ })
175
+ }
176
+
177
+ const results: Array<{
178
+ speaker: string
179
+ chunks: number
180
+ embeddingsExtracted: number
181
+ selected: number
182
+ enrolled: number
183
+ audioRetained?: boolean
184
+ }> = []
113
185
 
114
186
  for (const dir of speakerDirs) {
115
187
  const speakerName = dir.name.replace(/_/g, ' ')
@@ -127,28 +199,53 @@ voiceRouter.post('/voice/train-g2', async (req, res) => {
127
199
  }
128
200
 
129
201
  if (embeddings.length === 0) {
130
- results.push({ speaker: speakerName, chunks: wavFiles.length, enrolled: 0 })
202
+ results.push({ speaker: speakerName, chunks: wavFiles.length, embeddingsExtracted: 0, selected: 0, enrolled: 0, audioRetained: true })
131
203
  continue
132
204
  }
133
205
 
134
- // Enroll diverse embeddings (enrollEmbedding handles diversity gate + FIFO cap)
206
+ const selected = greedyDiversitySelect(embeddings, maxPerSpeaker)
207
+
208
+ if (dryRun) {
209
+ results.push({
210
+ speaker: speakerName,
211
+ chunks: wavFiles.length,
212
+ embeddingsExtracted: embeddings.length,
213
+ selected: selected.length,
214
+ enrolled: 0,
215
+ audioRetained: true,
216
+ })
217
+ continue
218
+ }
219
+
220
+ // Enroll the diverse subset (enrollEmbedding handles dedup gate + FIFO cap)
135
221
  let enrolled = 0
136
- for (const emb of embeddings) {
222
+ for (const emb of selected) {
137
223
  const result = enrollEmbedding(speakerName, emb, 'g2-training')
138
224
  if (result.success) enrolled++
139
225
  }
140
226
 
141
- results.push({ speaker: speakerName, chunks: wavFiles.length, enrolled })
142
-
143
- // Clean up processed audio
144
- for (const wav of wavFiles) {
145
- try { unlinkSync(resolve(speakerPath, wav)) } catch {}
227
+ results.push({
228
+ speaker: speakerName,
229
+ chunks: wavFiles.length,
230
+ embeddingsExtracted: embeddings.length,
231
+ selected: selected.length,
232
+ enrolled,
233
+ })
234
+
235
+ // Clean up processed audio. Only when something was actually enrolled —
236
+ // deleting the source after enrolling nothing is pure data loss.
237
+ if (enrolled > 0) {
238
+ for (const wav of wavFiles) {
239
+ try { unlinkSync(resolve(speakerPath, wav)) } catch {}
240
+ }
241
+ try { rmdirSync(speakerPath) } catch {}
242
+ } else {
243
+ results[results.length - 1].audioRetained = true
146
244
  }
147
- try { rmdirSync(speakerPath) } catch {}
148
245
  }
149
246
 
150
247
  const totalEnrolled = results.reduce((sum, r) => sum + r.enrolled, 0)
151
- res.json({ trained: totalEnrolled, speakers: results })
248
+ res.json({ trained: totalEnrolled, dryRun, maxPerSpeaker, speakers: results })
152
249
  } catch (err: unknown) {
153
250
  res.status(500).json({ error: errMsg(err) })
154
251
  }
@@ -236,11 +333,31 @@ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
236
333
  // Collect target directories
237
334
  const targetDirs: string[] = []
238
335
  if (sessionId) {
239
- const dirPath = resolve(EXT_AUDIO_DIR, sessionId)
240
- if (existsSync(dirPath)) targetDirs.push(dirPath)
336
+ const dirPath = speakerDirPath(EXT_AUDIO_DIR, String(sessionId))
337
+ if (dirPath && existsSync(dirPath)) targetDirs.push(dirPath)
241
338
  else return res.status(404).json({ error: `Session ${sessionId} not found in ext-audio` })
242
339
  } else {
243
340
  const sessionDirs = readdirSync(EXT_AUDIO_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
341
+ // Same reasoning as train-g2: with the reader path fixed, the unscoped
342
+ // form now attributes EVERY unrecognized session in the retention window
343
+ // to one person and then recursively deletes them all. Different sessions
344
+ // are usually different people, so that is a profile-poisoning default.
345
+ if (req.body?.confirmAllSessions !== true) {
346
+ const inventory = sessionDirs.map(d => {
347
+ let chunks = 0
348
+ try { chunks = readdirSync(resolve(EXT_AUDIO_DIR, d.name)).filter(f => f.endsWith('.wav')).length } catch {}
349
+ return { sessionId: d.name, chunks }
350
+ }).filter(s => s.chunks > 0)
351
+ return res.status(400).json({
352
+ error: 'confirmation required',
353
+ message: `Enrolling every ext-audio session as "${name}" assumes one speaker across all of them, `
354
+ + 'and deletes the audio afterwards. Pass { sessionId } to scope it, '
355
+ + 'or { confirmAllSessions: true } to proceed.',
356
+ wouldEnrollFrom: inventory,
357
+ totalSessions: inventory.length,
358
+ totalChunks: inventory.reduce((sum, s) => sum + s.chunks, 0),
359
+ })
360
+ }
244
361
  for (const d of sessionDirs) targetDirs.push(resolve(EXT_AUDIO_DIR, d.name))
245
362
  }
246
363
 
@@ -290,6 +407,214 @@ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
290
407
  }
291
408
  })
292
409
 
410
+ // GET /api/voice/profiles — enrolled people with sample counts and provenance.
411
+ // The review surfaces need to see the store; until now the only window into it
412
+ // was a per-name count, so a misattributed profile was invisible.
413
+ voiceRouter.get('/voice/profiles', (_req, res) => {
414
+ try {
415
+ const { profiles } = readVoiceProfiles()
416
+ const owner = getOwnerSpeakerLabel()
417
+ res.json({
418
+ owner,
419
+ count: profiles.length,
420
+ totalEmbeddings: profiles.reduce((sum, p) => sum + p.embeddings.length, 0),
421
+ profiles: profiles
422
+ .map(p => {
423
+ const bySource: Record<string, number> = {}
424
+ for (const source of p.sources ?? []) {
425
+ // Collapse auto:<sessionId> so one poisoned session is visible
426
+ // without leaking a session id per row.
427
+ const key = source.startsWith('auto:') ? 'auto' : source
428
+ bySource[key] = (bySource[key] ?? 0) + 1
429
+ }
430
+ return {
431
+ name: p.name,
432
+ embeddings: p.embeddings.length,
433
+ isOwner: p.name === owner,
434
+ sources: bySource,
435
+ // Provenance alignment is now an invariant; surfacing it makes a
436
+ // future regression visible instead of silent.
437
+ sourcesAligned: (p.sources?.length ?? 0) === p.embeddings.length,
438
+ }
439
+ })
440
+ .sort((a, b) => b.embeddings - a.embeddings),
441
+ })
442
+ } catch (err: unknown) {
443
+ res.status(500).json({ error: errMsg(err) })
444
+ }
445
+ })
446
+
447
+ // POST /api/voice/merge-profiles — fold two names for one person together.
448
+ // Body: { into, from: string[]|string, confirm: true, dryRun?, force? }
449
+ //
450
+ // Two profiles for one voice is worse than it looks: the sherpa manager holds
451
+ // one centroid per NAME, so both compete on every search and each is capped at
452
+ // 20 samples independently — 40 samples of one person, split, each half a
453
+ // weaker representation of them than the union would be.
454
+ //
455
+ // Fails closed below the search-accept threshold. A wrong merge destroys BOTH
456
+ // identities at once and cannot be undone from the store alone, so the only
457
+ // acceptable evidence is acoustic. `force` exists for the case where Miles
458
+ // knows something the audio does not, and it is logged.
459
+ voiceRouter.post('/voice/merge-profiles', (req, res) => {
460
+ try {
461
+ const into = typeof req.body?.into === 'string' ? req.body.into.trim() : ''
462
+ const rawFrom = req.body?.from
463
+ const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
464
+ .filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
465
+ .map((n: string) => n.trim())
466
+
467
+ if (!into || from.length === 0) {
468
+ return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
469
+ }
470
+ if (from.includes(into)) {
471
+ return res.status(400).json({ error: 'into and from must differ' })
472
+ }
473
+
474
+ const owner = getOwnerSpeakerLabel()
475
+ if (from.includes(owner)) {
476
+ // Absorbing the owner label would delete the profile the live
477
+ // identification path checks FIRST, on every chunk.
478
+ return res.status(400).json({
479
+ error: `refusing to absorb the owner label "${owner}" — merge INTO it instead`,
480
+ })
481
+ }
482
+
483
+ const dryRun = req.body?.dryRun === true
484
+ const force = req.body?.force === true
485
+
486
+ if (req.body?.confirm !== true && !dryRun) {
487
+ const preview = mergeSpeakerProfiles(into, from, { force, dryRun: true })
488
+ return res.status(400).json({
489
+ error: 'confirmation required',
490
+ message: `Merging is not reversible from the store alone. Review the similarity scores, then pass { confirm: true }.`,
491
+ preview,
492
+ })
493
+ }
494
+
495
+ const report = mergeSpeakerProfiles(into, from, { force, dryRun })
496
+
497
+ if (report.missing.length > 0 && report.merged.length === 0) {
498
+ return res.status(404).json({ error: 'no such profile(s)', missing: report.missing, report })
499
+ }
500
+ if (report.refused && report.merged.length === 0) {
501
+ return res.status(409).json({
502
+ error: 'similarity below the merge floor',
503
+ message: 'These centroids are further apart than the threshold at which identification would '
504
+ + 'accept a match between them, so they are probably different people. Pass { force: true } '
505
+ + 'only if you know they are the same person.',
506
+ report,
507
+ })
508
+ }
509
+
510
+ // Relabel rather than drop the absorbed name's calibration history: after a
511
+ // merge it is one person's history, and it is the only evidence for whether
512
+ // the merge improved identification.
513
+ const calibration: Record<string, number> = {}
514
+ if (!dryRun) {
515
+ for (const name of report.merged) {
516
+ calibration[name] = relabelSpeakerCalibrationRows(CALIBRATION_LOG, name, into).relabeled
517
+ }
518
+ }
519
+
520
+ res.json({ ...report, dryRun, forced: force, calibrationRowsRelabeled: calibration })
521
+ } catch (err: unknown) {
522
+ res.status(500).json({ error: errMsg(err) })
523
+ }
524
+ })
525
+
526
+ // POST /api/voice/delete-person — remove one person from every store that
527
+ // carries their name. Body: { name, confirm: true, dryRun? }
528
+ //
529
+ // Built before more data accumulates, and returns a per-store count so the sweep
530
+ // is auditable rather than a bare success. Two stores are deliberately NOT swept:
531
+ // ext-audio and session-audio are keyed by session, not by person, so there is no
532
+ // name to match on — they age out on their own retention instead.
533
+ voiceRouter.post('/voice/delete-person', (req, res) => {
534
+ try {
535
+ const name = req.body?.name
536
+ if (!name || typeof name !== 'string' || name.trim().length < 2) {
537
+ return res.status(400).json({ error: 'name is required (min 2 chars)' })
538
+ }
539
+ const target = name.trim()
540
+ const dryRun = req.body?.dryRun === true
541
+
542
+ if (req.body?.confirm !== true && !dryRun) {
543
+ const existing = readVoiceProfiles().profiles.find(p => p.name === target)
544
+ const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
545
+ let wavs = 0
546
+ if (audioDir && existsSync(audioDir)) {
547
+ try { wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav')).length } catch {}
548
+ }
549
+ const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target, { dryRun: true })
550
+ return res.status(400).json({
551
+ error: 'confirmation required',
552
+ message: `Deleting "${target}" is not reversible. Pass { confirm: true } to proceed.`,
553
+ wouldRemove: {
554
+ profile: existing ? 1 : 0,
555
+ embeddings: existing?.embeddings.length ?? 0,
556
+ trainingAudioFiles: wavs,
557
+ calibrationRows: calibration.removed,
558
+ },
559
+ })
560
+ }
561
+
562
+ if (dryRun) {
563
+ const existing = readVoiceProfiles().profiles.find(p => p.name === target)
564
+ const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
565
+ let wavs = 0
566
+ if (audioDir && existsSync(audioDir)) {
567
+ try { wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav')).length } catch {}
568
+ }
569
+ const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target, { dryRun: true })
570
+ return res.json({
571
+ name: target,
572
+ dryRun: true,
573
+ removed: {
574
+ profiles: existing ? 1 : 0,
575
+ embeddings: existing?.embeddings.length ?? 0,
576
+ trainingAudioFiles: wavs,
577
+ calibrationRows: calibration.removed,
578
+ },
579
+ })
580
+ }
581
+
582
+ // 1. Voice profile + sherpa manager registration.
583
+ const profileResult = removeSpeakerProfile(target)
584
+
585
+ // 2. Saved G2 training audio for this person.
586
+ let trainingAudioFiles = 0
587
+ const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
588
+ if (audioDir && existsSync(audioDir)) {
589
+ try {
590
+ const wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav'))
591
+ trainingAudioFiles = wavs.length
592
+ rmSync(audioDir, { recursive: true, force: true })
593
+ } catch { /* reported as 0 rather than claimed */ }
594
+ }
595
+
596
+ // 3. Calibration rows (the name appears in every row).
597
+ const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target)
598
+
599
+ res.json({
600
+ name: target,
601
+ removed: {
602
+ profiles: profileResult.removedProfiles,
603
+ embeddings: profileResult.removedEmbeddings,
604
+ trainingAudioFiles,
605
+ calibrationRows: calibration.removed,
606
+ },
607
+ notAttributable: {
608
+ extAudio: 'keyed by session, not by person — ages out on its own retention',
609
+ sessionAudio: 'keyed by session, not by person — ages out on its own retention',
610
+ },
611
+ calibrationRetained: calibration.retained,
612
+ })
613
+ } catch (err: unknown) {
614
+ res.status(500).json({ error: errMsg(err) })
615
+ }
616
+ })
617
+
293
618
  /** Greedy diversity selection — pick N most acoustically diverse embeddings */
294
619
  function greedyDiversitySelect(embeddings: Float32Array[], maxN: number): Float32Array[] {
295
620
  if (embeddings.length <= maxN) return embeddings