@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 +25 -0
- package/package.json +1 -1
- package/server/lib/audio-retention.ts +50 -0
- package/server/lib/speaker-calibration-log.ts +86 -0
- package/server/lib/speaker-embeddings.ts +162 -36
- package/server/lib/voice-profile-store.ts +365 -0
- package/server/routes/health.ts +11 -3
- package/server/routes/transcribe-stream.ts +46 -0
- package/server/routes/voice.ts +268 -22
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
|
@@ -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 {
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
profile.embeddings.push(Array.from(embedding))
|
|
636
|
-
profile.sources.push(source)
|
|
752
|
+
appendEmbedding(profile, Array.from(embedding), source)
|
|
637
753
|
|
|
638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] /=
|
|
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
|
-
|
|
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
|
|
847
|
+
if (!manager) return
|
|
722
848
|
|
|
723
849
|
try {
|
|
724
|
-
const store
|
|
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
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/server/routes/voice.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
220
|
+
// Enroll the diverse subset (enrollEmbedding handles dedup gate + FIFO cap)
|
|
135
221
|
let enrolled = 0
|
|
136
|
-
for (const emb of
|
|
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({
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 =
|
|
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
|