@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.
- package/CHANGELOG.md +77 -0
- package/package.json +1 -1
- package/server/lib/audio-retention.ts +50 -0
- package/server/lib/meeting-speaker-review.ts +268 -0
- package/server/lib/meeting-store.ts +57 -2
- package/server/lib/speaker-calibration-log.ts +146 -0
- package/server/lib/speaker-embeddings.ts +266 -36
- package/server/lib/voice-profile-store.ts +507 -0
- package/server/routes/health.ts +11 -3
- package/server/routes/meeting.ts +56 -1
- package/server/routes/transcribe-stream.ts +46 -0
- package/server/routes/voice.ts +347 -22
|
@@ -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 {
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
profile.embeddings.push(Array.from(embedding))
|
|
636
|
-
profile.sources.push(source)
|
|
856
|
+
appendEmbedding(profile, Array.from(embedding), source)
|
|
637
857
|
|
|
638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] /=
|
|
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
|
-
|
|
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
|
|
951
|
+
if (!manager) return
|
|
722
952
|
|
|
723
953
|
try {
|
|
724
|
-
const store
|
|
954
|
+
const store = loadProfileStore()
|
|
725
955
|
let loaded = 0
|
|
726
956
|
|
|
727
957
|
for (const profile of store.profiles) {
|