@gotcos/glasses-server 6.21.9 → 6.21.10

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 CHANGED
@@ -1,3 +1,28 @@
1
+ ## 6.21.10
2
+
3
+ - Make the voice profile store durable. `voice-profiles.json` is written
4
+ atomically with rotating hourly backups, a corrupt file is quarantined and
5
+ recovered from the newest usable backup, and a save can no longer replace a
6
+ populated store with an empty one.
7
+ - Keep embedding provenance aligned. `sources[]` is now added, evicted, and
8
+ repaired in lockstep with `embeddings[]`, and centroids use the modal
9
+ dimension so a single wrong-length row cannot reduce a speaker's registered
10
+ vector to NaN.
11
+ - Read saved speaker audio from the runtime data directory, matching where the
12
+ transcription pipeline writes it. `train-g2`, `saved-audio`, `ext-audio`, and
13
+ `enroll-ext` previously resolved a path inside the installed package and
14
+ reported an empty system on every managed install.
15
+ - Require confirmation before an unscoped `train-g2` or `enroll-ext` rewrites
16
+ profiles and deletes source audio, cap G2 training at ten diverse samples per
17
+ speaker so a large backlog cannot evict an existing profile, and retain source
18
+ audio whenever nothing was enrolled.
19
+ - Add `readiness.speakerId` to health. A voiceprint model that is installed but
20
+ rejected by the runtime now reports degraded instead of passing as working
21
+ diarization; an install with no model configured is unaffected.
22
+ - Expire saved training audio after 14 days per file, add a confirm-gated
23
+ `POST /api/voice/delete-person` that reports per-store removal counts, and add
24
+ `GET /api/voice/profiles` for review surfaces.
25
+
1
26
  ## 6.21.9
2
27
 
3
28
  - Prevent an unclosed or abandoned recording from monopolizing progressive HQ.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.9",
3
+ "version": "6.21.10",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,50 @@
1
+ // Age-based retention for saved speaker audio.
2
+ //
3
+ // The count cap on training-audio (30 WAVs per speaker) is a storage bound, not
4
+ // a retention policy: a speaker who is never trained keeps 30 chunks of their
5
+ // recorded voice forever, and the only path that ever deleted them was a manual
6
+ // /voice/train-g2 call. An unenforced retention policy is worse than none —
7
+ // it is a promise the code does not keep.
8
+ //
9
+ // Split out as a pure function so the expiry rule can be tested by execution
10
+ // without a clock, a filesystem, or a running server.
11
+
12
+ export interface RetentionCandidate {
13
+ name: string
14
+ mtimeMs: number
15
+ }
16
+
17
+ export interface RetentionSplit<T extends RetentionCandidate> {
18
+ expired: T[]
19
+ retained: T[]
20
+ }
21
+
22
+ /**
23
+ * Partition files by age. Per-file rather than per-directory: chunks for one
24
+ * speaker accumulate across weeks, so an all-or-nothing directory check either
25
+ * keeps month-old audio alive because one chunk is fresh, or deletes today's
26
+ * capture because the directory is old.
27
+ *
28
+ * A file with an unreadable/zero mtime is RETAINED. Treating "I could not read
29
+ * the timestamp" as "this is ancient" would delete data on the strength of a
30
+ * failed stat.
31
+ */
32
+ export function partitionExpiredAudio<T extends RetentionCandidate>(
33
+ files: T[],
34
+ nowMs: number,
35
+ ttlMs: number,
36
+ ): RetentionSplit<T> {
37
+ const expired: T[] = []
38
+ const retained: T[] = []
39
+ for (const file of files) {
40
+ const age = nowMs - file.mtimeMs
41
+ if (file.mtimeMs > 0 && Number.isFinite(file.mtimeMs) && age > ttlMs) expired.push(file)
42
+ else retained.push(file)
43
+ }
44
+ return { expired, retained }
45
+ }
46
+
47
+ /** Human-readable age, for the log line that reports a purge. */
48
+ export function ageHours(mtimeMs: number, nowMs: number): number {
49
+ return Math.round(((nowMs - mtimeMs) / (60 * 60 * 1000)) * 10) / 10
50
+ }
@@ -0,0 +1,86 @@
1
+ // speaker-calibration.jsonl — threshold-tuning telemetry, one row per
2
+ // identification decision. Every row carries a speaker NAME, so a person's
3
+ // trace survives deleting their voice profile unless this file is swept too.
4
+ //
5
+ // The log is append-only and written from the live identification path with a
6
+ // fire-and-forget `appendFileSync`. A rewrite therefore has a genuine (if
7
+ // millisecond-wide) race with concurrent appends. That is acceptable HERE and
8
+ // nowhere else in this feature: the file is explicitly non-critical tuning data,
9
+ // already best-effort, and losing a row written during the swap costs nothing —
10
+ // whereas leaving a deleted person's name in 21k rows is the privacy gap the
11
+ // delete exists to close. Embedding data is never touched by this module.
12
+
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
15
+
16
+ export interface CalibrationPurgeResult {
17
+ /** Rows whose `speaker` matched and were dropped. */
18
+ removed: number
19
+ /** Rows retained, including rows that could not be parsed. */
20
+ retained: number
21
+ /** Rows that were not valid JSON. Kept — a malformed row is not evidence that
22
+ * it belongs to the person being deleted, and discarding it would be a
23
+ * silent data loss dressed up as a privacy fix. */
24
+ unparsable: number
25
+ }
26
+
27
+ /**
28
+ * Filter JSONL text, dropping rows whose `speaker` field matches.
29
+ *
30
+ * Pure so the matching rule can be tested without touching the filesystem.
31
+ * Matching is exact on the `speaker` field only: a substring match would delete
32
+ * every "Miles Mallard" row when removing "Miles", and the name also appears in
33
+ * no other field.
34
+ */
35
+ export function filterCalibrationRows(
36
+ raw: string,
37
+ speakerName: string,
38
+ ): { text: string; result: CalibrationPurgeResult } {
39
+ const lines = raw.split('\n')
40
+ const kept: string[] = []
41
+ const result: CalibrationPurgeResult = { removed: 0, retained: 0, unparsable: 0 }
42
+
43
+ for (const line of lines) {
44
+ if (line.trim() === '') continue
45
+ let speaker: unknown
46
+ try {
47
+ speaker = (JSON.parse(line) as { speaker?: unknown }).speaker
48
+ } catch {
49
+ result.unparsable++
50
+ result.retained++
51
+ kept.push(line)
52
+ continue
53
+ }
54
+ if (speaker === speakerName) {
55
+ result.removed++
56
+ continue
57
+ }
58
+ result.retained++
59
+ kept.push(line)
60
+ }
61
+
62
+ return { text: kept.length > 0 ? kept.join('\n') + '\n' : '', result }
63
+ }
64
+
65
+ /** Rewrite the log without a given speaker's rows. */
66
+ export function purgeSpeakerCalibrationRows(
67
+ logPath: string,
68
+ speakerName: string,
69
+ options: { dryRun?: boolean } = {},
70
+ ): CalibrationPurgeResult {
71
+ if (!existsSync(logPath)) return { removed: 0, retained: 0, unparsable: 0 }
72
+ let raw: string
73
+ try {
74
+ raw = readFileSync(logPath, 'utf-8')
75
+ } catch {
76
+ return { removed: 0, retained: 0, unparsable: 0 }
77
+ }
78
+
79
+ const { text, result } = filterCalibrationRows(raw, speakerName)
80
+ if (result.removed === 0 || options.dryRun) return result
81
+
82
+ // Atomic: a torn rewrite of a 2 MB log would leave a half-row that every
83
+ // later parse trips over.
84
+ durableAtomicWriteFileSync(logPath, text, { mode: 0o600 })
85
+ return result
86
+ }
@@ -8,7 +8,20 @@
8
8
  import { resolve } from 'node:path'
9
9
  import { errMsg } from './utils.js'
10
10
  import { getOwnerSpeakerLabel } from './profile.js'
11
- import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'
11
+ import { existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'
12
+ import {
13
+ appendEmbedding,
14
+ deleteProfileFromStore,
15
+ describeRepairs,
16
+ dropOldestEmbedding,
17
+ hasRepairs,
18
+ loadVoiceProfileStore,
19
+ modalDimension,
20
+ removeEmbeddingsBySource,
21
+ saveVoiceProfileStore,
22
+ type ProfileStore,
23
+ type VoiceProfile,
24
+ } from './voice-profile-store.js'
12
25
  import { homedir } from 'node:os'
13
26
  import { spawnSync } from 'node:child_process'
14
27
  import { fileURLToPath } from 'node:url'
@@ -92,15 +105,10 @@ const autoEnrollSessions = new Map<string, Map<string, number>>()
92
105
  // Track which speakers already auto-enrolled this session
93
106
  const autoEnrolledThisSession = new Map<string, Set<string>>()
94
107
 
95
- interface VoiceProfile {
96
- name: string
97
- embeddings: number[][] // multiple enrollments for robustness
98
- sources?: string[] // provenance: 'manual' | 'fireflies' | 'auto:sessionId'
99
- }
100
-
101
- interface ProfileStore {
102
- profiles: VoiceProfile[]
103
- }
108
+ // Shape, durability, and integrity repair all live in voice-profile-store.ts.
109
+ // Re-exported because the review surfaces (/speakers, delete-person, coherence
110
+ // audit) need the types and were previously blocked on them being file-private.
111
+ export type { ProfileStore, VoiceProfile }
104
112
 
105
113
  /** Cheap structural screen for a downloaded model.
106
114
  *
@@ -293,11 +301,13 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
293
301
  }
294
302
  }
295
303
 
296
- // FIFO cap: if at max, drop oldest before adding (always enforced)
304
+ // FIFO cap: if at max, drop oldest before adding (always enforced).
305
+ // dropOldestEmbedding keeps sources[] in lockstep — the old inline
306
+ // `sources?.shift()` no-opped whenever sources was undefined or short,
307
+ // permanently offsetting provenance from the samples it described.
297
308
  if (profile.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
298
309
  console.log(`[speaker] Profile cap reached for "${name}" (${profile.embeddings.length}/${MAX_EMBEDDINGS_PER_SPEAKER}) — dropping oldest`)
299
- profile.embeddings.shift()
300
- profile.sources?.shift()
310
+ dropOldestEmbedding(profile)
301
311
  rebuildSpeakerInManager(name, profile.embeddings)
302
312
  }
303
313
  }
@@ -448,10 +458,44 @@ export function clearSpeakerEmbeddings(name: string): boolean {
448
458
  try { manager.remove(name) } catch { /* ignore */ }
449
459
  }
450
460
 
451
- // Persist
452
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
453
- invalidateProfileCache()
454
- return true
461
+ // Persist. A "fresh training" clear legitimately empties one profile but must
462
+ // never be able to empty the whole store, so allowEmpty stays off here.
463
+ return writeProfileStore(store)
464
+ }
465
+
466
+ /** Remove a person entirely: profile, embeddings, and manager registration.
467
+ * Returns counts so a caller can report what was actually removed. */
468
+ export function removeSpeakerProfile(name: string): { removedProfiles: number; removedEmbeddings: number } {
469
+ const store = loadProfileStore()
470
+ const result = deleteProfileFromStore(store, name)
471
+ if (result.removedProfiles === 0) return result
472
+
473
+ if (manager && manager.contains(name)) {
474
+ try { manager.remove(name) } catch { /* the on-disk removal is the durable half */ }
475
+ }
476
+ // allowEmpty: deleting the only enrolled person is a legitimate reset, and the
477
+ // caller has already confirmed it explicitly.
478
+ writeProfileStore(store, { allowEmpty: true })
479
+ return result
480
+ }
481
+
482
+ /** Retract samples by provenance — e.g. every `auto:<sessionId>` embedding a
483
+ * bad session wrote into the wrong profile. `clearSpeakerEmbeddings` is
484
+ * all-or-nothing and would discard the legitimate training alongside it. */
485
+ export function retractEmbeddingsBySource(
486
+ name: string,
487
+ matches: (source: string) => boolean,
488
+ ): { removed: number; remaining: number } {
489
+ const store = loadProfileStore()
490
+ const profile = store.profiles.find(p => p.name === name)
491
+ if (!profile) return { removed: 0, remaining: 0 }
492
+
493
+ const removed = removeEmbeddingsBySource(profile, matches)
494
+ if (removed === 0) return { removed: 0, remaining: profile.embeddings.length }
495
+
496
+ rebuildSpeakerInManager(name, profile.embeddings)
497
+ writeProfileStore(store, { allowEmpty: true })
498
+ return { removed, remaining: profile.embeddings.length }
455
499
  }
456
500
 
457
501
  /** Get embedding count for a speaker */
@@ -495,6 +539,28 @@ export function speakerModelState(): {
495
539
  return { state, path, searched: speakerModelCandidates() }
496
540
  }
497
541
 
542
+ /** Map the model state onto a readiness verdict for /api/health.
543
+ *
544
+ * The distinction this encodes is the whole point of the field, so it is a
545
+ * named pure function rather than an inline ternary inside the health handler:
546
+ *
547
+ * - 'error' → degraded. A model IS installed and the runtime refused it,
548
+ * so diarization has silently collapsed to the amplitude
549
+ * fallback while every other status surface stays green.
550
+ * That is exactly how 78 trained profiles went unnoticed as
551
+ * missing across a managed cutover.
552
+ * - 'unavailable' → NOT degraded. The ~26 MB model ships outside the npm
553
+ * tarball, so most installs have never had one and are
554
+ * working as designed. Degrading them would make the field
555
+ * meaningless on every public box and train the operator to
556
+ * ignore the one channel that matters. */
557
+ export function speakerReadiness(
558
+ state: 'active' | 'unavailable' | 'error',
559
+ ): 'ready' | 'unavailable' | 'degraded' {
560
+ if (state === 'error') return 'degraded'
561
+ return state === 'active' ? 'ready' : 'unavailable'
562
+ }
563
+
498
564
  /** Compute actual cosine similarity between two raw embedding vectors */
499
565
  export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
500
566
  if (a.length !== b.length) return 0
@@ -606,14 +672,66 @@ function logCalibration(speaker: string, similarity: number, matched: boolean, e
606
672
  } catch { /* non-critical */ }
607
673
  }
608
674
 
609
- /** Load profile store from disk (cached in memory, invalidated on write) */
675
+ /** Load profile store from disk (cached in memory, invalidated on write).
676
+ *
677
+ * Previously a bare `JSON.parse(readFileSync(...))`: a truncated file threw out
678
+ * of every caller, including the live enrollment path, and there was no backup
679
+ * to fall back to. Corruption and integrity repairs are now both reported —
680
+ * silently returning `{profiles: []}` is the one outcome that must never pass
681
+ * unremarked, because the next save would commit it over the real store. */
610
682
  function loadProfileStore(): ProfileStore {
611
683
  if (_cachedProfileStore) return _cachedProfileStore
612
- if (existsSync(PROFILES_PATH)) {
613
- _cachedProfileStore = JSON.parse(readFileSync(PROFILES_PATH, 'utf-8'))
614
- return _cachedProfileStore!
684
+
685
+ const load = loadVoiceProfileStore(PROFILES_PATH)
686
+
687
+ if (load.status === 'corrupt') {
688
+ if (load.recoveredFromBackup) {
689
+ console.error(
690
+ `[speaker] voice-profiles.json was corrupt (quarantined as ${load.quarantinedAs}) —`,
691
+ `recovered ${load.store.profiles.length} profile(s) from ${load.recoveredFromBackup}.`,
692
+ )
693
+ // Republish the recovered content so the next boot reads a clean file
694
+ // rather than repeating the recovery. The corrupt original is retained at
695
+ // the quarantine path for inspection.
696
+ try {
697
+ saveVoiceProfileStore(PROFILES_PATH, load.store)
698
+ } catch (err: unknown) {
699
+ console.error('[speaker] Failed to republish recovered profiles:', errMsg(err))
700
+ }
701
+ } else {
702
+ console.error(
703
+ `[speaker] voice-profiles.json was corrupt and NO usable backup exists.`,
704
+ `The unreadable file is retained at ${load.quarantinedAs}.`,
705
+ 'Diarization is starting with zero profiles; writes will not overwrite a populated store.',
706
+ )
707
+ }
708
+ }
709
+
710
+ if (hasRepairs(load.repairs)) {
711
+ console.warn(`[speaker] Repaired voice-profiles.json on load: ${describeRepairs(load.repairs)}`)
615
712
  }
616
- return { profiles: [] }
713
+
714
+ _cachedProfileStore = load.store
715
+ return _cachedProfileStore
716
+ }
717
+
718
+ /** Read-only view of the persisted profiles, for review/audit surfaces.
719
+ * Returns a structural copy so a caller cannot mutate the shared cache. */
720
+ export function readVoiceProfiles(): ProfileStore {
721
+ const store = loadProfileStore()
722
+ return { profiles: store.profiles.map(p => ({ ...p, embeddings: p.embeddings, sources: [...(p.sources ?? [])] })) }
723
+ }
724
+
725
+ /** Persist the shared store, reporting a refusal rather than swallowing it. */
726
+ function writeProfileStore(store: ProfileStore, options: { allowEmpty?: boolean } = {}): boolean {
727
+ const result = saveVoiceProfileStore(PROFILES_PATH, store, options)
728
+ if (!result.written) {
729
+ console.error(`[speaker] Profile store write refused: ${result.refusedReason}`)
730
+ return false
731
+ }
732
+ if (result.backup) console.log(`[speaker] Profile store backed up to ${result.backup}`)
733
+ invalidateProfileCache()
734
+ return true
617
735
  }
618
736
 
619
737
  /** Invalidate the in-memory profile store cache (call after any write to PROFILES_PATH) */
@@ -631,12 +749,9 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
631
749
  profile = { name, embeddings: [], sources: [] }
632
750
  store.profiles.push(profile)
633
751
  }
634
- if (!profile.sources) profile.sources = []
635
- profile.embeddings.push(Array.from(embedding))
636
- profile.sources.push(source)
752
+ appendEmbedding(profile, Array.from(embedding), source)
637
753
 
638
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
639
- invalidateProfileCache()
754
+ writeProfileStore(store)
640
755
  } catch (err: unknown) {
641
756
  console.error('[speaker] Profile persist error:', errMsg(err))
642
757
  }
@@ -645,17 +760,24 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
645
760
  /** Compute centroid (average) of multiple embeddings.
646
761
  * The centroid captures the speaker's average voice across different acoustic
647
762
  * conditions (meetings, mics, energy levels). More robust than any single embedding. */
648
- function computeCentroid(embeddings: number[][]): Float32Array {
649
- const dim = embeddings[0].length
763
+ export function computeCentroid(embeddings: number[][]): Float32Array {
764
+ // Dimension-safe: one wrong-length row used to read `undefined` past its end
765
+ // and turn EVERY component of the averaged vector into NaN, which sherpa then
766
+ // registers as the speaker's only representative vector. Skip mismatches
767
+ // instead, and take the modal dimension so a corrupt row 0 cannot define it.
768
+ const dim = modalDimension(embeddings)
650
769
  const centroid = new Float32Array(dim)
651
- for (const emb of embeddings) {
770
+ if (dim === 0) return centroid
771
+ const usable = embeddings.filter(emb => emb.length === dim)
772
+ if (usable.length === 0) return centroid
773
+ for (const emb of usable) {
652
774
  for (let i = 0; i < dim; i++) {
653
775
  centroid[i] += emb[i]
654
776
  }
655
777
  }
656
778
  // Average
657
779
  for (let i = 0; i < dim; i++) {
658
- centroid[i] /= embeddings.length
780
+ centroid[i] /= usable.length
659
781
  }
660
782
  // L2 normalize (important for cosine similarity)
661
783
  let norm = 0
@@ -695,8 +817,7 @@ function rebuildSpeakerInManager(name: string, embeddings: number[][]): void {
695
817
 
696
818
  /** Save full profile store to disk (used by trainer for bulk updates) */
697
819
  export function saveProfileStore(store: ProfileStore): void {
698
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
699
- invalidateProfileCache()
820
+ writeProfileStore(store)
700
821
  }
701
822
 
702
823
  /** Rebuild all profiles in manager from a store (used after bulk training) */
@@ -716,12 +837,17 @@ export function rebuildAllProfiles(store: ProfileStore): void {
716
837
  console.log(`[speaker] Rebuilt manager: ${loaded} speakers (centroid mode)`)
717
838
  }
718
839
 
719
- /** Load persisted profiles into manager */
840
+ /** Load persisted profiles into manager.
841
+ *
842
+ * Goes through loadProfileStore() rather than re-reading the file: a second
843
+ * independent `JSON.parse` here meant boot and the enrollment path could
844
+ * disagree about the store's contents, and it bypassed both the corrupt-file
845
+ * recovery and the integrity repairs. */
720
846
  function loadProfiles(): void {
721
- if (!manager || !existsSync(PROFILES_PATH)) return
847
+ if (!manager) return
722
848
 
723
849
  try {
724
- const store: ProfileStore = JSON.parse(readFileSync(PROFILES_PATH, 'utf-8'))
850
+ const store = loadProfileStore()
725
851
  let loaded = 0
726
852
 
727
853
  for (const profile of store.profiles) {
@@ -0,0 +1,365 @@
1
+ // Durability and integrity for voice-profiles.json.
2
+ //
3
+ // This file is the single most irreplaceable artifact the server owns: on this
4
+ // box it holds 79 profiles / 1,317 embeddings / ~7.8 MB, accumulated over months
5
+ // of training that cannot be re-derived once the source audio has aged out. It
6
+ // was being written with three raw `writeFileSync` calls and read with an
7
+ // unguarded `JSON.parse`, so one crash, disk-full, or iCloud-sync mid-write
8
+ // would take all of it and every subsequent read would throw.
9
+ //
10
+ // Everything here is a pure function over an explicit path so it can be tested
11
+ // by execution rather than by asserting on source text. speaker-embeddings.ts
12
+ // delegates to it and keeps the sherpa-onnx manager in sync.
13
+
14
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
15
+ import { basename, dirname, join, resolve } from 'node:path'
16
+ import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
17
+
18
+ export interface VoiceProfile {
19
+ name: string
20
+ /** One row per enrolled sample. Parallel to `sources`, index for index. */
21
+ embeddings: number[][]
22
+ /** Provenance: 'manual' | 'fireflies' | 'g2-training' | 'auto:<sessionId>' | … */
23
+ sources?: string[]
24
+ }
25
+
26
+ export interface ProfileStore {
27
+ profiles: VoiceProfile[]
28
+ }
29
+
30
+ /** What normalization had to fix. All non-zero counts are worth logging: they
31
+ * mean the file on disk had drifted from its own invariants. */
32
+ export interface StoreRepairs {
33
+ /** `sources` was missing, short, or long relative to `embeddings`. */
34
+ sourcesRealigned: number
35
+ /** A null/non-string slot inside `sources` (observed in the live store). */
36
+ sourcesCoerced: number
37
+ /** Embedding rows that were not arrays of finite numbers — unusable. */
38
+ embeddingsDropped: number
39
+ /** Rows whose length differs from the profile's modal dimension. Kept on
40
+ * disk, but excluded from centroids: sherpa compares by cosine, and a
41
+ * short row silently produces NaN across the whole averaged vector. */
42
+ dimensionMismatch: number
43
+ /** Entries with no usable name, or duplicate names collapsed. */
44
+ profilesDropped: number
45
+ }
46
+
47
+ export function emptyRepairs(): StoreRepairs {
48
+ return {
49
+ sourcesRealigned: 0,
50
+ sourcesCoerced: 0,
51
+ embeddingsDropped: 0,
52
+ dimensionMismatch: 0,
53
+ profilesDropped: 0,
54
+ }
55
+ }
56
+
57
+ export function hasRepairs(r: StoreRepairs): boolean {
58
+ return r.sourcesRealigned > 0 || r.sourcesCoerced > 0 || r.embeddingsDropped > 0
59
+ || r.dimensionMismatch > 0 || r.profilesDropped > 0
60
+ }
61
+
62
+ export function describeRepairs(r: StoreRepairs): string {
63
+ const parts: string[] = []
64
+ if (r.profilesDropped) parts.push(`${r.profilesDropped} unusable profile(s)`)
65
+ if (r.embeddingsDropped) parts.push(`${r.embeddingsDropped} unusable embedding row(s)`)
66
+ if (r.sourcesRealigned) parts.push(`${r.sourcesRealigned} profile(s) with misaligned sources[]`)
67
+ if (r.sourcesCoerced) parts.push(`${r.sourcesCoerced} null/non-string source slot(s)`)
68
+ if (r.dimensionMismatch) parts.push(`${r.dimensionMismatch} wrong-dimension embedding(s) excluded from centroids`)
69
+ return parts.join(', ')
70
+ }
71
+
72
+ const UNKNOWN_SOURCE = 'unknown'
73
+
74
+ function isUsableEmbedding(row: unknown): row is number[] {
75
+ return Array.isArray(row) && row.length > 0 && row.every(v => typeof v === 'number' && Number.isFinite(v))
76
+ }
77
+
78
+ /** The dimension the majority of a profile's rows agree on.
79
+ *
80
+ * Not `embeddings[0].length`: if row 0 happens to be the corrupt one, every
81
+ * other row becomes the "mismatch" and the profile is emptied. */
82
+ export function modalDimension(embeddings: number[][]): number {
83
+ const counts = new Map<number, number>()
84
+ for (const row of embeddings) counts.set(row.length, (counts.get(row.length) ?? 0) + 1)
85
+ let best = 0, bestCount = -1
86
+ for (const [dim, count] of counts) {
87
+ // Tie-break toward the larger dimension: a truncated row is the likelier
88
+ // corruption than a systematically longer one.
89
+ if (count > bestCount || (count === bestCount && dim > best)) { best = dim; bestCount = count }
90
+ }
91
+ return best
92
+ }
93
+
94
+ /** Bring a parsed store back to its invariants, reporting every change.
95
+ *
96
+ * Deliberately conservative: rows that are merely the wrong DIMENSION are kept
97
+ * on disk and only excluded from centroid math, because they may be a
98
+ * recoverable model change rather than corruption. Only rows that cannot be
99
+ * numbers at all are dropped. */
100
+ export function normalizeProfileStore(raw: unknown): { store: ProfileStore; repairs: StoreRepairs } {
101
+ const repairs = emptyRepairs()
102
+ const rawProfiles = (raw as ProfileStore | null)?.profiles
103
+ if (!Array.isArray(rawProfiles)) return { store: { profiles: [] }, repairs }
104
+
105
+ const byName = new Map<string, VoiceProfile>()
106
+ for (const candidate of rawProfiles) {
107
+ const name = typeof (candidate as VoiceProfile)?.name === 'string'
108
+ ? (candidate as VoiceProfile).name.trim()
109
+ : ''
110
+ if (!name) { repairs.profilesDropped++; continue }
111
+
112
+ const rawEmbeddings = Array.isArray((candidate as VoiceProfile).embeddings)
113
+ ? (candidate as VoiceProfile).embeddings as unknown[]
114
+ : []
115
+ const rawSources = Array.isArray((candidate as VoiceProfile).sources)
116
+ ? (candidate as VoiceProfile).sources as unknown[]
117
+ : null
118
+
119
+ // Walk both arrays together so dropping row i also drops source i — the
120
+ // exact coupling `embeddings.shift()` + `sources?.shift()` used to break.
121
+ const embeddings: number[][] = []
122
+ const sources: string[] = []
123
+ for (let i = 0; i < rawEmbeddings.length; i++) {
124
+ const row = rawEmbeddings[i]
125
+ if (!isUsableEmbedding(row)) { repairs.embeddingsDropped++; continue }
126
+ embeddings.push(row)
127
+ const source = rawSources?.[i]
128
+ if (typeof source === 'string' && source.length > 0) {
129
+ sources.push(source)
130
+ } else {
131
+ // Missing (sources shorter than embeddings) or a null/non-string slot.
132
+ if (rawSources && i < rawSources.length) repairs.sourcesCoerced++
133
+ sources.push(UNKNOWN_SOURCE)
134
+ }
135
+ }
136
+
137
+ const wasMisaligned = rawSources === null
138
+ ? embeddings.length > 0
139
+ : rawSources.length !== rawEmbeddings.length
140
+ if (wasMisaligned) repairs.sourcesRealigned++
141
+
142
+ const dim = modalDimension(embeddings)
143
+ for (const row of embeddings) if (row.length !== dim) repairs.dimensionMismatch++
144
+
145
+ const existing = byName.get(name)
146
+ if (existing) {
147
+ // Duplicate profile names cannot both be registered in the manager (one
148
+ // vector per name), so the later rows would be invisible. Merge instead.
149
+ repairs.profilesDropped++
150
+ existing.embeddings.push(...embeddings)
151
+ existing.sources!.push(...sources)
152
+ continue
153
+ }
154
+ byName.set(name, { name, embeddings, sources })
155
+ }
156
+
157
+ return { store: { profiles: [...byName.values()] }, repairs }
158
+ }
159
+
160
+ export type StoreLoad = {
161
+ store: ProfileStore
162
+ repairs: StoreRepairs
163
+ /** 'missing' is normal on a fresh install. 'corrupt' means the previous file
164
+ * was quarantined and the training in it is GONE unless a backup is used. */
165
+ status: 'ok' | 'missing' | 'corrupt'
166
+ quarantinedAs?: string
167
+ /** Set when `status: 'corrupt'` and a backup was successfully substituted. */
168
+ recoveredFromBackup?: string
169
+ }
170
+
171
+ export function backupDirFor(storePath: string): string {
172
+ return join(dirname(storePath), `${basename(storePath, '.json')}.backups`)
173
+ }
174
+
175
+ /** Newest first. */
176
+ export function listProfileStoreBackups(storePath: string): string[] {
177
+ const dir = backupDirFor(storePath)
178
+ if (!existsSync(dir)) return []
179
+ try {
180
+ return readdirSync(dir)
181
+ .filter(f => f.endsWith('.json'))
182
+ .map(f => resolve(dir, f))
183
+ .map(p => ({ p, mtime: safeMtime(p) }))
184
+ .sort((a, b) => b.mtime - a.mtime)
185
+ .map(entry => entry.p)
186
+ } catch { return [] }
187
+ }
188
+
189
+ function safeMtime(path: string): number {
190
+ try { return statSync(path).mtimeMs } catch { return 0 }
191
+ }
192
+
193
+ function safeSize(path: string): number {
194
+ try { return statSync(path).size } catch { return 0 }
195
+ }
196
+
197
+ export const PROFILE_STORE_BACKUP_KEEP = 5
198
+ /** Don't copy 7.8 MB on every enroll; one snapshot per hour bounds the churn
199
+ * while still guaranteeing a recent restore point. */
200
+ export const PROFILE_STORE_BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000
201
+
202
+ /**
203
+ * Load the store, quarantining a corrupt file and falling back to the newest
204
+ * usable backup rather than silently starting from zero profiles.
205
+ *
206
+ * Starting empty is the dangerous default here: the very next `saveProfileStore`
207
+ * would durably persist that empty store over the only remaining copy.
208
+ */
209
+ export function loadVoiceProfileStore(storePath: string): StoreLoad {
210
+ const result = loadJsonOrQuarantine<unknown>(storePath)
211
+ if (result.status === 'missing') {
212
+ return { store: { profiles: [] }, repairs: emptyRepairs(), status: 'missing' }
213
+ }
214
+ if (result.status === 'ok') {
215
+ const { store, repairs } = normalizeProfileStore(result.data)
216
+ return { store, repairs, status: 'ok' }
217
+ }
218
+
219
+ for (const backup of listProfileStoreBackups(storePath)) {
220
+ const restored = loadJsonOrQuarantine<unknown>(backup)
221
+ if (restored.status !== 'ok') continue
222
+ const { store, repairs } = normalizeProfileStore(restored.data)
223
+ if (store.profiles.length === 0) continue
224
+ return {
225
+ store,
226
+ repairs,
227
+ status: 'corrupt',
228
+ quarantinedAs: result.quarantinedAs,
229
+ recoveredFromBackup: backup,
230
+ }
231
+ }
232
+ return {
233
+ store: { profiles: [] },
234
+ repairs: emptyRepairs(),
235
+ status: 'corrupt',
236
+ quarantinedAs: result.quarantinedAs,
237
+ }
238
+ }
239
+
240
+ /** Snapshot the current file before it is overwritten, then prune old copies. */
241
+ export function backupProfileStore(
242
+ storePath: string,
243
+ options: { nowMs?: number; force?: boolean; keep?: number; minIntervalMs?: number } = {},
244
+ ): string | null {
245
+ if (!existsSync(storePath)) return null
246
+ if (safeSize(storePath) === 0) return null
247
+
248
+ const now = options.nowMs ?? Date.now()
249
+ const keep = options.keep ?? PROFILE_STORE_BACKUP_KEEP
250
+ const minInterval = options.minIntervalMs ?? PROFILE_STORE_BACKUP_MIN_INTERVAL_MS
251
+ const existing = listProfileStoreBackups(storePath)
252
+ if (!options.force && existing.length > 0 && now - safeMtime(existing[0]) < minInterval) return null
253
+
254
+ const dir = backupDirFor(storePath)
255
+ try {
256
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
257
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-')
258
+ const target = join(dir, `${basename(storePath, '.json')}.${stamp}.json`)
259
+ // Copy through the durable writer so a backup can never itself be torn.
260
+ const raw = loadJsonOrQuarantine<unknown>(storePath)
261
+ if (raw.status !== 'ok') return null
262
+ durableAtomicWriteFileSync(target, JSON.stringify(raw.data), { mode: 0o600 })
263
+ for (const stale of listProfileStoreBackups(storePath).slice(keep)) {
264
+ try { unlinkSync(stale) } catch { /* pruning is best effort */ }
265
+ }
266
+ return target
267
+ } catch {
268
+ // A failed backup must never block the write it precedes.
269
+ return null
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Persist the store atomically, after taking a rotating backup.
275
+ *
276
+ * Refuses to write an empty store over a populated file. That is the shape of
277
+ * every catastrophic loss here — a failed load returning `{profiles: []}`, then
278
+ * a routine save committing it — and no legitimate caller needs it. Callers
279
+ * that really mean it (a full reset) pass `allowEmpty`.
280
+ */
281
+ export function saveVoiceProfileStore(
282
+ storePath: string,
283
+ store: ProfileStore,
284
+ options: { allowEmpty?: boolean; nowMs?: number } = {},
285
+ ): { written: boolean; backup: string | null; refusedReason?: string } {
286
+ if (store.profiles.length === 0 && !options.allowEmpty) {
287
+ const current = loadJsonOrQuarantine<ProfileStore>(storePath)
288
+ if (current.status === 'ok' && (current.data?.profiles?.length ?? 0) > 0) {
289
+ return {
290
+ written: false,
291
+ backup: null,
292
+ refusedReason: `refusing to overwrite ${current.data.profiles.length} stored profile(s) with an empty store`,
293
+ }
294
+ }
295
+ }
296
+
297
+ const backup = backupProfileStore(storePath, { nowMs: options.nowMs })
298
+ mkdirSync(dirname(storePath), { recursive: true, mode: 0o700 })
299
+ durableAtomicWriteFileSync(storePath, JSON.stringify(store, null, 2), { mode: 0o600 })
300
+ return { written: true, backup }
301
+ }
302
+
303
+ /** Drop the oldest sample, keeping `embeddings` and `sources` in lockstep.
304
+ *
305
+ * The old inline form was `embeddings.shift()` followed by `sources?.shift()`,
306
+ * which silently skipped the second half whenever `sources` was undefined and
307
+ * desynchronised provenance from that point on — permanently, since every
308
+ * later push appended to both. */
309
+ export function dropOldestEmbedding(profile: VoiceProfile): { droppedSource: string | null } {
310
+ if (profile.embeddings.length === 0) return { droppedSource: null }
311
+ profile.embeddings.shift()
312
+ if (!Array.isArray(profile.sources)) profile.sources = []
313
+ // Re-align first so a short sources[] cannot shift the wrong provenance off.
314
+ while (profile.sources.length < profile.embeddings.length + 1) profile.sources.push(UNKNOWN_SOURCE)
315
+ const droppedSource = profile.sources.shift() ?? null
316
+ profile.sources.length = profile.embeddings.length
317
+ return { droppedSource }
318
+ }
319
+
320
+ /** Append a sample to both arrays together. */
321
+ export function appendEmbedding(profile: VoiceProfile, embedding: number[], source: string): void {
322
+ if (!Array.isArray(profile.sources)) profile.sources = []
323
+ while (profile.sources.length < profile.embeddings.length) profile.sources.push(UNKNOWN_SOURCE)
324
+ profile.embeddings.push(embedding)
325
+ profile.sources.push(source)
326
+ }
327
+
328
+ /** Remove every sample whose provenance matches, e.g. one poisoned session.
329
+ * Returns the number removed so a retraction can be reported rather than
330
+ * assumed. */
331
+ export function removeEmbeddingsBySource(
332
+ profile: VoiceProfile,
333
+ predicate: (source: string) => boolean,
334
+ ): number {
335
+ if (!Array.isArray(profile.sources)) profile.sources = []
336
+ while (profile.sources.length < profile.embeddings.length) profile.sources.push(UNKNOWN_SOURCE)
337
+ const keptEmbeddings: number[][] = []
338
+ const keptSources: string[] = []
339
+ let removed = 0
340
+ for (let i = 0; i < profile.embeddings.length; i++) {
341
+ if (predicate(profile.sources[i] ?? UNKNOWN_SOURCE)) { removed++; continue }
342
+ keptEmbeddings.push(profile.embeddings[i])
343
+ keptSources.push(profile.sources[i] ?? UNKNOWN_SOURCE)
344
+ }
345
+ profile.embeddings = keptEmbeddings
346
+ profile.sources = keptSources
347
+ return removed
348
+ }
349
+
350
+ /** Delete a person's profile outright. Returns what was removed so the caller
351
+ * can report a per-store count instead of a bare success. */
352
+ export function deleteProfileFromStore(
353
+ store: ProfileStore,
354
+ name: string,
355
+ ): { removedProfiles: number; removedEmbeddings: number } {
356
+ let removedProfiles = 0
357
+ let removedEmbeddings = 0
358
+ store.profiles = store.profiles.filter(profile => {
359
+ if (profile.name !== name) return true
360
+ removedProfiles++
361
+ removedEmbeddings += profile.embeddings.length
362
+ return false
363
+ })
364
+ return { removedProfiles, removedEmbeddings }
365
+ }
@@ -6,7 +6,7 @@ import { serverMetrics } from '../lib/server-metrics.js'
6
6
  import { getServerInstanceId } from '../lib/server-instance-id.js'
7
7
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
8
8
  import { isSileroAvailable } from '../lib/vad-silero.js'
9
- import { speakerModelState } from '../lib/speaker-embeddings.js'
9
+ import { speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
10
10
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
11
  import {
12
12
  isWhisperLocalAvailable,
@@ -113,7 +113,8 @@ healthRouter.get('/health', async (_req, res) => {
113
113
  // npm tarball), so publish its state rather than letting the amplitude
114
114
  // fallback masquerade as working diarization. Availability only — the resolved
115
115
  // path is a local filesystem detail and health is unauthenticated.
116
- checks.speaker_id = speakerModelState().state
116
+ const speakerId = speakerModelState()
117
+ checks.speaker_id = speakerId.state
117
118
 
118
119
  // Health is unauthenticated. Publish only availability; the actual CLI
119
120
  // session id is a resumable runtime handle and belongs on authenticated
@@ -176,15 +177,22 @@ healthRouter.get('/health', async (_req, res) => {
176
177
  : whisper_health.startupState === 'preflight' || whisper_health.startupState === 'loading'
177
178
  ? 'starting'
178
179
  : 'degraded'
180
+ // A configured-but-broken voiceprint model is a fault, not a preference. The
181
+ // full reasoning, including why 'unavailable' must NOT degrade, lives with
182
+ // speakerReadiness() so it is testable rather than an inline ternary here.
183
+ const speakerReadinessState = speakerReadiness(speakerId.state)
179
184
  const readiness = {
180
185
  // /api/health remains a liveness endpoint and intentionally returns HTTP
181
186
  // 200 while the server can answer. This separate field prevents an HTTP-
182
187
  // green response from hiding a configured local subsystem failure.
183
- status: whisperReadiness === 'degraded' ? 'degraded' : 'ready',
188
+ status: whisperReadiness === 'degraded' || speakerReadinessState === 'degraded' ? 'degraded' : 'ready',
184
189
  admissions: maintenance.admissionsOpen ? 'open' : 'maintenance',
185
190
  whisper: whisperReadiness,
186
191
  whisperError: whisper_health.lastError,
187
192
  localTts: tts_local.ready ? 'ready' : 'unavailable',
193
+ // Asserted, not merely reported: `speaker_id` above says what the state IS,
194
+ // this says whether that state is acceptable.
195
+ speakerId: speakerReadinessState,
188
196
  }
189
197
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
190
198
  const codex_models = getCodexModelCatalogSnapshot()
@@ -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 } 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 } 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 })
203
+ continue
204
+ }
205
+
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
+ })
131
217
  continue
132
218
  }
133
219
 
134
- // Enroll diverse embeddings (enrollEmbedding handles diversity gate + FIFO cap)
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,135 @@ 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/delete-person — remove one person from every store that
448
+ // carries their name. Body: { name, confirm: true, dryRun? }
449
+ //
450
+ // Built before more data accumulates, and returns a per-store count so the sweep
451
+ // is auditable rather than a bare success. Two stores are deliberately NOT swept:
452
+ // ext-audio and session-audio are keyed by session, not by person, so there is no
453
+ // name to match on — they age out on their own retention instead.
454
+ voiceRouter.post('/voice/delete-person', (req, res) => {
455
+ try {
456
+ const name = req.body?.name
457
+ if (!name || typeof name !== 'string' || name.trim().length < 2) {
458
+ return res.status(400).json({ error: 'name is required (min 2 chars)' })
459
+ }
460
+ const target = name.trim()
461
+ const dryRun = req.body?.dryRun === true
462
+
463
+ if (req.body?.confirm !== true && !dryRun) {
464
+ const existing = readVoiceProfiles().profiles.find(p => p.name === target)
465
+ const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
466
+ let wavs = 0
467
+ if (audioDir && existsSync(audioDir)) {
468
+ try { wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav')).length } catch {}
469
+ }
470
+ const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target, { dryRun: true })
471
+ return res.status(400).json({
472
+ error: 'confirmation required',
473
+ message: `Deleting "${target}" is not reversible. Pass { confirm: true } to proceed.`,
474
+ wouldRemove: {
475
+ profile: existing ? 1 : 0,
476
+ embeddings: existing?.embeddings.length ?? 0,
477
+ trainingAudioFiles: wavs,
478
+ calibrationRows: calibration.removed,
479
+ },
480
+ })
481
+ }
482
+
483
+ if (dryRun) {
484
+ const existing = readVoiceProfiles().profiles.find(p => p.name === target)
485
+ const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
486
+ let wavs = 0
487
+ if (audioDir && existsSync(audioDir)) {
488
+ try { wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav')).length } catch {}
489
+ }
490
+ const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target, { dryRun: true })
491
+ return res.json({
492
+ name: target,
493
+ dryRun: true,
494
+ removed: {
495
+ profiles: existing ? 1 : 0,
496
+ embeddings: existing?.embeddings.length ?? 0,
497
+ trainingAudioFiles: wavs,
498
+ calibrationRows: calibration.removed,
499
+ },
500
+ })
501
+ }
502
+
503
+ // 1. Voice profile + sherpa manager registration.
504
+ const profileResult = removeSpeakerProfile(target)
505
+
506
+ // 2. Saved G2 training audio for this person.
507
+ let trainingAudioFiles = 0
508
+ const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
509
+ if (audioDir && existsSync(audioDir)) {
510
+ try {
511
+ const wavs = readdirSync(audioDir).filter(f => f.endsWith('.wav'))
512
+ trainingAudioFiles = wavs.length
513
+ rmSync(audioDir, { recursive: true, force: true })
514
+ } catch { /* reported as 0 rather than claimed */ }
515
+ }
516
+
517
+ // 3. Calibration rows (the name appears in every row).
518
+ const calibration = purgeSpeakerCalibrationRows(CALIBRATION_LOG, target)
519
+
520
+ res.json({
521
+ name: target,
522
+ removed: {
523
+ profiles: profileResult.removedProfiles,
524
+ embeddings: profileResult.removedEmbeddings,
525
+ trainingAudioFiles,
526
+ calibrationRows: calibration.removed,
527
+ },
528
+ notAttributable: {
529
+ extAudio: 'keyed by session, not by person — ages out on its own retention',
530
+ sessionAudio: 'keyed by session, not by person — ages out on its own retention',
531
+ },
532
+ calibrationRetained: calibration.retained,
533
+ })
534
+ } catch (err: unknown) {
535
+ res.status(500).json({ error: errMsg(err) })
536
+ }
537
+ })
538
+
293
539
  /** Greedy diversity selection — pick N most acoustically diverse embeddings */
294
540
  function greedyDiversitySelect(embeddings: Float32Array[], maxN: number): Float32Array[] {
295
541
  if (embeddings.length <= maxN) return embeddings