@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.
@@ -8,7 +8,22 @@
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
+ mergeProfilesInStore,
16
+ profileSimilarity,
17
+ describeRepairs,
18
+ dropOldestEmbedding,
19
+ hasRepairs,
20
+ loadVoiceProfileStore,
21
+ modalDimension,
22
+ removeEmbeddingsBySource,
23
+ saveVoiceProfileStore,
24
+ type ProfileStore,
25
+ type VoiceProfile,
26
+ } from './voice-profile-store.js'
12
27
  import { homedir } from 'node:os'
13
28
  import { spawnSync } from 'node:child_process'
14
29
  import { fileURLToPath } from 'node:url'
@@ -92,15 +107,10 @@ const autoEnrollSessions = new Map<string, Map<string, number>>()
92
107
  // Track which speakers already auto-enrolled this session
93
108
  const autoEnrolledThisSession = new Map<string, Set<string>>()
94
109
 
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
- }
110
+ // Shape, durability, and integrity repair all live in voice-profile-store.ts.
111
+ // Re-exported because the review surfaces (/speakers, delete-person, coherence
112
+ // audit) need the types and were previously blocked on them being file-private.
113
+ export type { ProfileStore, VoiceProfile }
104
114
 
105
115
  /** Cheap structural screen for a downloaded model.
106
116
  *
@@ -293,11 +303,13 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
293
303
  }
294
304
  }
295
305
 
296
- // FIFO cap: if at max, drop oldest before adding (always enforced)
306
+ // FIFO cap: if at max, drop oldest before adding (always enforced).
307
+ // dropOldestEmbedding keeps sources[] in lockstep — the old inline
308
+ // `sources?.shift()` no-opped whenever sources was undefined or short,
309
+ // permanently offsetting provenance from the samples it described.
297
310
  if (profile.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
298
311
  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()
312
+ dropOldestEmbedding(profile)
301
313
  rebuildSpeakerInManager(name, profile.embeddings)
302
314
  }
303
315
  }
@@ -448,10 +460,146 @@ export function clearSpeakerEmbeddings(name: string): boolean {
448
460
  try { manager.remove(name) } catch { /* ignore */ }
449
461
  }
450
462
 
451
- // Persist
452
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
453
- invalidateProfileCache()
454
- return true
463
+ // Persist. A "fresh training" clear legitimately empties one profile but must
464
+ // never be able to empty the whole store, so allowEmpty stays off here.
465
+ return writeProfileStore(store)
466
+ }
467
+
468
+ /** The floor a merge must clear on centroid cosine.
469
+ *
470
+ * Set at the search-accept threshold on purpose: if two profiles are further
471
+ * apart than the value at which identification would accept a match between
472
+ * them, they are not the same voice and merging them would poison both. */
473
+ export const MERGE_SIMILARITY_FLOOR = SEARCH_THRESHOLD
474
+
475
+ export interface MergeReport {
476
+ into: string
477
+ merged: string[]
478
+ missing: string[]
479
+ similarity: Record<string, number>
480
+ samplesBefore: number
481
+ samplesAfter: number
482
+ droppedToCap: number
483
+ refused?: { name: string; similarity: number; floor: number }[]
484
+ }
485
+
486
+ /**
487
+ * Fold profiles together — two names for one person, e.g. "Luke H" and
488
+ * "Luke Henry" at 0.843.
489
+ *
490
+ * Fails closed below MERGE_SIMILARITY_FLOOR unless `force`. That guard is the
491
+ * whole safety story: a wrong merge destroys BOTH identities at once, and the
492
+ * only evidence that two names are one person is acoustic, never the names
493
+ * themselves.
494
+ */
495
+ export function mergeSpeakerProfiles(
496
+ into: string,
497
+ from: string[],
498
+ options: { force?: boolean; dryRun?: boolean } = {},
499
+ ): MergeReport {
500
+ const store = loadProfileStore()
501
+ const target = store.profiles.find(p => p.name === into)
502
+ const report: MergeReport = {
503
+ into,
504
+ merged: [],
505
+ missing: [],
506
+ similarity: {},
507
+ samplesBefore: target?.embeddings.length ?? 0,
508
+ samplesAfter: target?.embeddings.length ?? 0,
509
+ droppedToCap: 0,
510
+ }
511
+ if (!target) {
512
+ report.missing = [into]
513
+ return report
514
+ }
515
+
516
+ // Score every candidate first so a dry run and a refusal report the same
517
+ // numbers the real merge would act on.
518
+ const eligible: string[] = []
519
+ const refused: { name: string; similarity: number; floor: number }[] = []
520
+ for (const name of from) {
521
+ if (name === into) continue
522
+ const source = store.profiles.find(p => p.name === name)
523
+ if (!source) { report.missing.push(name); continue }
524
+ const similarity = profileSimilarity(target, source)
525
+ report.similarity[name] = Math.round(similarity * 1000) / 1000
526
+ if (similarity < MERGE_SIMILARITY_FLOOR && !options.force) {
527
+ refused.push({ name, similarity: report.similarity[name], floor: MERGE_SIMILARITY_FLOOR })
528
+ continue
529
+ }
530
+ eligible.push(name)
531
+ }
532
+ if (refused.length > 0) report.refused = refused
533
+ if (eligible.length === 0) return report
534
+
535
+ if (options.dryRun) {
536
+ // Report on a throwaway copy so nothing is mutated by a preview.
537
+ const preview = JSON.parse(JSON.stringify(store)) as ProfileStore
538
+ const outcome = mergeProfilesInStore(preview, into, eligible, { cap: MAX_EMBEDDINGS_PER_SPEAKER })
539
+ report.merged = outcome.mergedFrom
540
+ report.samplesAfter = outcome.samplesAfter
541
+ report.droppedToCap = outcome.droppedToCap
542
+ return report
543
+ }
544
+
545
+ const outcome = mergeProfilesInStore(store, into, eligible, { cap: MAX_EMBEDDINGS_PER_SPEAKER })
546
+ report.merged = outcome.mergedFrom
547
+ report.samplesAfter = outcome.samplesAfter
548
+ report.droppedToCap = outcome.droppedToCap
549
+
550
+ // Manager first: drop the absorbed names so a stale centroid cannot keep
551
+ // winning searches after its profile is gone, then re-register the target
552
+ // from its new combined sample set.
553
+ //
554
+ // Routed through rebuildSpeakerInManager rather than an inline
555
+ // contains()/remove() pair: that helper already removes-then-returns on an
556
+ // empty sample list, so this is the one manager-mutation path in the file
557
+ // instead of a second one that could drift. NOTE: the manager is absent in
558
+ // tests (no 26 MB model), so this side effect has no test seam — the reason
559
+ // it is expressed as a single reused call rather than bespoke logic.
560
+ for (const name of outcome.mergedFrom) rebuildSpeakerInManager(name, [])
561
+ rebuildSpeakerInManager(into, target.embeddings)
562
+ writeProfileStore(store)
563
+ console.log(
564
+ `[speaker] Merged ${outcome.mergedFrom.join(', ')} into "${into}"`,
565
+ `(${report.samplesBefore} + absorbed → ${report.samplesAfter}, ${report.droppedToCap} dropped to cap)`,
566
+ )
567
+ return report
568
+ }
569
+
570
+ /** Remove a person entirely: profile, embeddings, and manager registration.
571
+ * Returns counts so a caller can report what was actually removed. */
572
+ export function removeSpeakerProfile(name: string): { removedProfiles: number; removedEmbeddings: number } {
573
+ const store = loadProfileStore()
574
+ const result = deleteProfileFromStore(store, name)
575
+ if (result.removedProfiles === 0) return result
576
+
577
+ if (manager && manager.contains(name)) {
578
+ try { manager.remove(name) } catch { /* the on-disk removal is the durable half */ }
579
+ }
580
+ // allowEmpty: deleting the only enrolled person is a legitimate reset, and the
581
+ // caller has already confirmed it explicitly.
582
+ writeProfileStore(store, { allowEmpty: true })
583
+ return result
584
+ }
585
+
586
+ /** Retract samples by provenance — e.g. every `auto:<sessionId>` embedding a
587
+ * bad session wrote into the wrong profile. `clearSpeakerEmbeddings` is
588
+ * all-or-nothing and would discard the legitimate training alongside it. */
589
+ export function retractEmbeddingsBySource(
590
+ name: string,
591
+ matches: (source: string) => boolean,
592
+ ): { removed: number; remaining: number } {
593
+ const store = loadProfileStore()
594
+ const profile = store.profiles.find(p => p.name === name)
595
+ if (!profile) return { removed: 0, remaining: 0 }
596
+
597
+ const removed = removeEmbeddingsBySource(profile, matches)
598
+ if (removed === 0) return { removed: 0, remaining: profile.embeddings.length }
599
+
600
+ rebuildSpeakerInManager(name, profile.embeddings)
601
+ writeProfileStore(store, { allowEmpty: true })
602
+ return { removed, remaining: profile.embeddings.length }
455
603
  }
456
604
 
457
605
  /** Get embedding count for a speaker */
@@ -495,6 +643,28 @@ export function speakerModelState(): {
495
643
  return { state, path, searched: speakerModelCandidates() }
496
644
  }
497
645
 
646
+ /** Map the model state onto a readiness verdict for /api/health.
647
+ *
648
+ * The distinction this encodes is the whole point of the field, so it is a
649
+ * named pure function rather than an inline ternary inside the health handler:
650
+ *
651
+ * - 'error' → degraded. A model IS installed and the runtime refused it,
652
+ * so diarization has silently collapsed to the amplitude
653
+ * fallback while every other status surface stays green.
654
+ * That is exactly how 78 trained profiles went unnoticed as
655
+ * missing across a managed cutover.
656
+ * - 'unavailable' → NOT degraded. The ~26 MB model ships outside the npm
657
+ * tarball, so most installs have never had one and are
658
+ * working as designed. Degrading them would make the field
659
+ * meaningless on every public box and train the operator to
660
+ * ignore the one channel that matters. */
661
+ export function speakerReadiness(
662
+ state: 'active' | 'unavailable' | 'error',
663
+ ): 'ready' | 'unavailable' | 'degraded' {
664
+ if (state === 'error') return 'degraded'
665
+ return state === 'active' ? 'ready' : 'unavailable'
666
+ }
667
+
498
668
  /** Compute actual cosine similarity between two raw embedding vectors */
499
669
  export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
500
670
  if (a.length !== b.length) return 0
@@ -606,14 +776,66 @@ function logCalibration(speaker: string, similarity: number, matched: boolean, e
606
776
  } catch { /* non-critical */ }
607
777
  }
608
778
 
609
- /** Load profile store from disk (cached in memory, invalidated on write) */
779
+ /** Load profile store from disk (cached in memory, invalidated on write).
780
+ *
781
+ * Previously a bare `JSON.parse(readFileSync(...))`: a truncated file threw out
782
+ * of every caller, including the live enrollment path, and there was no backup
783
+ * to fall back to. Corruption and integrity repairs are now both reported —
784
+ * silently returning `{profiles: []}` is the one outcome that must never pass
785
+ * unremarked, because the next save would commit it over the real store. */
610
786
  function loadProfileStore(): ProfileStore {
611
787
  if (_cachedProfileStore) return _cachedProfileStore
612
- if (existsSync(PROFILES_PATH)) {
613
- _cachedProfileStore = JSON.parse(readFileSync(PROFILES_PATH, 'utf-8'))
614
- return _cachedProfileStore!
788
+
789
+ const load = loadVoiceProfileStore(PROFILES_PATH)
790
+
791
+ if (load.status === 'corrupt') {
792
+ if (load.recoveredFromBackup) {
793
+ console.error(
794
+ `[speaker] voice-profiles.json was corrupt (quarantined as ${load.quarantinedAs}) —`,
795
+ `recovered ${load.store.profiles.length} profile(s) from ${load.recoveredFromBackup}.`,
796
+ )
797
+ // Republish the recovered content so the next boot reads a clean file
798
+ // rather than repeating the recovery. The corrupt original is retained at
799
+ // the quarantine path for inspection.
800
+ try {
801
+ saveVoiceProfileStore(PROFILES_PATH, load.store)
802
+ } catch (err: unknown) {
803
+ console.error('[speaker] Failed to republish recovered profiles:', errMsg(err))
804
+ }
805
+ } else {
806
+ console.error(
807
+ `[speaker] voice-profiles.json was corrupt and NO usable backup exists.`,
808
+ `The unreadable file is retained at ${load.quarantinedAs}.`,
809
+ 'Diarization is starting with zero profiles; writes will not overwrite a populated store.',
810
+ )
811
+ }
812
+ }
813
+
814
+ if (hasRepairs(load.repairs)) {
815
+ console.warn(`[speaker] Repaired voice-profiles.json on load: ${describeRepairs(load.repairs)}`)
615
816
  }
616
- return { profiles: [] }
817
+
818
+ _cachedProfileStore = load.store
819
+ return _cachedProfileStore
820
+ }
821
+
822
+ /** Read-only view of the persisted profiles, for review/audit surfaces.
823
+ * Returns a structural copy so a caller cannot mutate the shared cache. */
824
+ export function readVoiceProfiles(): ProfileStore {
825
+ const store = loadProfileStore()
826
+ return { profiles: store.profiles.map(p => ({ ...p, embeddings: p.embeddings, sources: [...(p.sources ?? [])] })) }
827
+ }
828
+
829
+ /** Persist the shared store, reporting a refusal rather than swallowing it. */
830
+ function writeProfileStore(store: ProfileStore, options: { allowEmpty?: boolean } = {}): boolean {
831
+ const result = saveVoiceProfileStore(PROFILES_PATH, store, options)
832
+ if (!result.written) {
833
+ console.error(`[speaker] Profile store write refused: ${result.refusedReason}`)
834
+ return false
835
+ }
836
+ if (result.backup) console.log(`[speaker] Profile store backed up to ${result.backup}`)
837
+ invalidateProfileCache()
838
+ return true
617
839
  }
618
840
 
619
841
  /** Invalidate the in-memory profile store cache (call after any write to PROFILES_PATH) */
@@ -631,12 +853,9 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
631
853
  profile = { name, embeddings: [], sources: [] }
632
854
  store.profiles.push(profile)
633
855
  }
634
- if (!profile.sources) profile.sources = []
635
- profile.embeddings.push(Array.from(embedding))
636
- profile.sources.push(source)
856
+ appendEmbedding(profile, Array.from(embedding), source)
637
857
 
638
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
639
- invalidateProfileCache()
858
+ writeProfileStore(store)
640
859
  } catch (err: unknown) {
641
860
  console.error('[speaker] Profile persist error:', errMsg(err))
642
861
  }
@@ -645,17 +864,24 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
645
864
  /** Compute centroid (average) of multiple embeddings.
646
865
  * The centroid captures the speaker's average voice across different acoustic
647
866
  * conditions (meetings, mics, energy levels). More robust than any single embedding. */
648
- function computeCentroid(embeddings: number[][]): Float32Array {
649
- const dim = embeddings[0].length
867
+ export function computeCentroid(embeddings: number[][]): Float32Array {
868
+ // Dimension-safe: one wrong-length row used to read `undefined` past its end
869
+ // and turn EVERY component of the averaged vector into NaN, which sherpa then
870
+ // registers as the speaker's only representative vector. Skip mismatches
871
+ // instead, and take the modal dimension so a corrupt row 0 cannot define it.
872
+ const dim = modalDimension(embeddings)
650
873
  const centroid = new Float32Array(dim)
651
- for (const emb of embeddings) {
874
+ if (dim === 0) return centroid
875
+ const usable = embeddings.filter(emb => emb.length === dim)
876
+ if (usable.length === 0) return centroid
877
+ for (const emb of usable) {
652
878
  for (let i = 0; i < dim; i++) {
653
879
  centroid[i] += emb[i]
654
880
  }
655
881
  }
656
882
  // Average
657
883
  for (let i = 0; i < dim; i++) {
658
- centroid[i] /= embeddings.length
884
+ centroid[i] /= usable.length
659
885
  }
660
886
  // L2 normalize (important for cosine similarity)
661
887
  let norm = 0
@@ -695,8 +921,7 @@ function rebuildSpeakerInManager(name: string, embeddings: number[][]): void {
695
921
 
696
922
  /** Save full profile store to disk (used by trainer for bulk updates) */
697
923
  export function saveProfileStore(store: ProfileStore): void {
698
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
699
- invalidateProfileCache()
924
+ writeProfileStore(store)
700
925
  }
701
926
 
702
927
  /** Rebuild all profiles in manager from a store (used after bulk training) */
@@ -716,12 +941,17 @@ export function rebuildAllProfiles(store: ProfileStore): void {
716
941
  console.log(`[speaker] Rebuilt manager: ${loaded} speakers (centroid mode)`)
717
942
  }
718
943
 
719
- /** Load persisted profiles into manager */
944
+ /** Load persisted profiles into manager.
945
+ *
946
+ * Goes through loadProfileStore() rather than re-reading the file: a second
947
+ * independent `JSON.parse` here meant boot and the enrollment path could
948
+ * disagree about the store's contents, and it bypassed both the corrupt-file
949
+ * recovery and the integrity repairs. */
720
950
  function loadProfiles(): void {
721
- if (!manager || !existsSync(PROFILES_PATH)) return
951
+ if (!manager) return
722
952
 
723
953
  try {
724
- const store: ProfileStore = JSON.parse(readFileSync(PROFILES_PATH, 'utf-8'))
954
+ const store = loadProfileStore()
725
955
  let loaded = 0
726
956
 
727
957
  for (const profile of store.profiles) {