@gotcos/glasses-server 6.21.7 → 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/.env.example +14 -0
- package/CHANGELOG.md +48 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/index.ts +5 -1
- package/server/lib/audio-retention.ts +50 -0
- package/server/lib/batch-transcript-quality.ts +1 -1
- package/server/lib/g2-enrichment-runner.ts +23 -6
- package/server/lib/g2-ops-handoff.ts +193 -32
- package/server/lib/meeting-batch-transcribe.ts +554 -10
- package/server/lib/meeting-finalization-jobs.ts +235 -0
- package/server/lib/meeting-store.ts +9 -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/lib/whisper-local.ts +77 -6
- package/server/routes/health.ts +26 -3
- package/server/routes/meeting.ts +268 -52
- package/server/routes/transcribe-stream.ts +113 -2
- package/server/routes/voice.ts +268 -22
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
3
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
4
|
+
import { dataPath } from './data-dir.js'
|
|
5
|
+
|
|
6
|
+
const JOB_NAME = /^[A-Za-z0-9:_-]{3,96}\.json$/
|
|
7
|
+
|
|
8
|
+
export interface MeetingFinalizationJob {
|
|
9
|
+
schemaVersion: 1
|
|
10
|
+
sessionId: string
|
|
11
|
+
meetingPath: string
|
|
12
|
+
sidecarPath: string
|
|
13
|
+
audioDir: string | null
|
|
14
|
+
streamingWordCount: number
|
|
15
|
+
phase: 'capture_pending' | 'batch_pending' | 'ops_pending'
|
|
16
|
+
claimPending: boolean
|
|
17
|
+
createdAt: string
|
|
18
|
+
updatedAt: string
|
|
19
|
+
lastError?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function contained(parent: string, child: string): boolean {
|
|
23
|
+
return child === parent || child.startsWith(`${parent}${sep}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validJob(raw: unknown): raw is MeetingFinalizationJob {
|
|
27
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
|
|
28
|
+
const job = raw as Partial<MeetingFinalizationJob>
|
|
29
|
+
return job.schemaVersion === 1
|
|
30
|
+
&& typeof job.sessionId === 'string'
|
|
31
|
+
&& /^[A-Za-z0-9:_-]{3,96}$/.test(job.sessionId)
|
|
32
|
+
&& typeof job.meetingPath === 'string'
|
|
33
|
+
&& typeof job.sidecarPath === 'string'
|
|
34
|
+
&& (job.audioDir === null || typeof job.audioDir === 'string')
|
|
35
|
+
&& typeof job.streamingWordCount === 'number'
|
|
36
|
+
&& Number.isFinite(job.streamingWordCount)
|
|
37
|
+
&& (job.phase === 'capture_pending' || job.phase === 'batch_pending' || job.phase === 'ops_pending')
|
|
38
|
+
&& (job.claimPending === undefined || typeof job.claimPending === 'boolean')
|
|
39
|
+
&& typeof job.createdAt === 'string'
|
|
40
|
+
&& typeof job.updatedAt === 'string'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Durable replay ledger for the post-response HQ + operations handoff.
|
|
44
|
+
* The meeting and sidecar remain the canonical data; this store contains only
|
|
45
|
+
* bounded pointers and phase state so a server restart can resume safely. */
|
|
46
|
+
export class MeetingFinalizationJobStore {
|
|
47
|
+
readonly root: string
|
|
48
|
+
|
|
49
|
+
constructor(root = dataPath('meeting-finalization-jobs')) {
|
|
50
|
+
this.root = resolve(root)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private ensureRoot(): void {
|
|
54
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private pathFor(sessionId: string): string {
|
|
58
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) throw new Error('Invalid finalization sessionId')
|
|
59
|
+
const path = resolve(this.root, `${sessionId}.json`)
|
|
60
|
+
if (!contained(this.root, path)) throw new Error('Unsafe finalization job path')
|
|
61
|
+
return path
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private assertSafePointers(input: Pick<MeetingFinalizationJob, 'meetingPath' | 'sidecarPath' | 'audioDir'>): void {
|
|
65
|
+
const dataRoot = dirname(this.root)
|
|
66
|
+
const recordingsRoot = resolve(dataRoot, 'recordings')
|
|
67
|
+
const pendingRoot = resolve(dataRoot, 'pending-batch')
|
|
68
|
+
if (!contained(recordingsRoot, resolve(input.meetingPath))) throw new Error('Unsafe finalization meeting path')
|
|
69
|
+
if (!contained(recordingsRoot, resolve(input.sidecarPath))) throw new Error('Unsafe finalization sidecar path')
|
|
70
|
+
if (input.audioDir && !contained(pendingRoot, resolve(input.audioDir))) {
|
|
71
|
+
throw new Error('Unsafe finalization audio path')
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
save(input: Omit<MeetingFinalizationJob, 'schemaVersion' | 'createdAt' | 'updatedAt'>): MeetingFinalizationJob {
|
|
76
|
+
this.assertSafePointers(input)
|
|
77
|
+
this.ensureRoot()
|
|
78
|
+
const prior = this.get(input.sessionId)
|
|
79
|
+
const now = new Date().toISOString()
|
|
80
|
+
const job: MeetingFinalizationJob = {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
...input,
|
|
83
|
+
createdAt: prior?.createdAt ?? now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
}
|
|
86
|
+
durableAtomicWriteFileSync(this.pathFor(input.sessionId), `${JSON.stringify(job, null, 2)}\n`, { mode: 0o600 })
|
|
87
|
+
return job
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get(sessionId: string): MeetingFinalizationJob | null {
|
|
91
|
+
const path = this.pathFor(sessionId)
|
|
92
|
+
if (!existsSync(path)) return null
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
95
|
+
if (!validJob(parsed)) return null
|
|
96
|
+
const normalized = { ...parsed, claimPending: parsed.claimPending === true }
|
|
97
|
+
this.assertSafePointers(normalized)
|
|
98
|
+
return normalized
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
list(): MeetingFinalizationJob[] {
|
|
105
|
+
if (!existsSync(this.root)) return []
|
|
106
|
+
let names: string[] = []
|
|
107
|
+
try { names = readdirSync(this.root).filter(name => JOB_NAME.test(name)) } catch { return [] }
|
|
108
|
+
return names.flatMap(name => {
|
|
109
|
+
const sessionId = basename(name, '.json')
|
|
110
|
+
const job = this.get(sessionId)
|
|
111
|
+
return job ? [job] : []
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
malformedCount(): number {
|
|
116
|
+
if (!existsSync(this.root)) return 0
|
|
117
|
+
try {
|
|
118
|
+
return readdirSync(this.root)
|
|
119
|
+
.filter(name => JOB_NAME.test(name))
|
|
120
|
+
.filter(name => this.get(basename(name, '.json')) === null)
|
|
121
|
+
.length
|
|
122
|
+
} catch {
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
remove(sessionId: string): void {
|
|
128
|
+
try { unlinkSync(this.pathFor(sessionId)) } catch { /* already absent */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Find a previously moved audio directory without trusting a stored path. */
|
|
132
|
+
findPendingAudioDir(sessionId: string): string | null {
|
|
133
|
+
const pendingRoot = resolve(dirname(this.root), 'pending-batch')
|
|
134
|
+
if (!existsSync(pendingRoot)) return null
|
|
135
|
+
try {
|
|
136
|
+
const candidates = readdirSync(pendingRoot)
|
|
137
|
+
.filter(name => name === sessionId || name.startsWith(`${sessionId}_`))
|
|
138
|
+
.map(name => resolve(pendingRoot, name))
|
|
139
|
+
.filter(path => contained(pendingRoot, path) && statSync(path).isDirectory())
|
|
140
|
+
.sort()
|
|
141
|
+
return candidates[0] ?? null
|
|
142
|
+
} catch {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Rebuild missing replay jobs from the canonical sidecar intent marker. */
|
|
148
|
+
reconcileCanonicalSidecars(): MeetingFinalizationJob[] {
|
|
149
|
+
const recordingsRoot = resolve(dirname(this.root), 'recordings')
|
|
150
|
+
if (!existsSync(recordingsRoot)) return []
|
|
151
|
+
const rebuilt: MeetingFinalizationJob[] = []
|
|
152
|
+
let months: string[] = []
|
|
153
|
+
try { months = readdirSync(recordingsRoot).filter(name => /^\d{4}-\d{2}$/.test(name)) } catch { return [] }
|
|
154
|
+
for (const month of months) {
|
|
155
|
+
const monthDir = resolve(recordingsRoot, month)
|
|
156
|
+
let names: string[] = []
|
|
157
|
+
try { names = readdirSync(monthDir).filter(name => name.endsWith('.g2-chunks.json')) } catch { continue }
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
const sidecarPath = resolve(monthDir, name)
|
|
160
|
+
try {
|
|
161
|
+
const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
|
|
162
|
+
const sessionId = typeof sidecar.sessionId === 'string' ? sidecar.sessionId : ''
|
|
163
|
+
if (sidecar.finalizationState === 'complete' || !sidecar.finalizationState || this.get(sessionId)) continue
|
|
164
|
+
const meetingPath = sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
165
|
+
if (!existsSync(meetingPath)) continue
|
|
166
|
+
const audioDir = this.findPendingAudioDir(sessionId)
|
|
167
|
+
rebuilt.push(this.save({
|
|
168
|
+
sessionId,
|
|
169
|
+
meetingPath,
|
|
170
|
+
sidecarPath,
|
|
171
|
+
audioDir,
|
|
172
|
+
streamingWordCount: Number(sidecar.streamingWordCount ?? 0),
|
|
173
|
+
phase: audioDir ? 'batch_pending' : 'capture_pending',
|
|
174
|
+
claimPending: sidecar.claimPending === true,
|
|
175
|
+
}))
|
|
176
|
+
} catch { /* malformed canonical sidecars are not executable */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return rebuilt
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function readFinalizationChunkEntries(job: MeetingFinalizationJob): unknown[] | null {
|
|
184
|
+
if (!existsSync(job.meetingPath) || !existsSync(job.sidecarPath)) return null
|
|
185
|
+
try {
|
|
186
|
+
const sidecar = JSON.parse(readFileSync(job.sidecarPath, 'utf8')) as Record<string, unknown>
|
|
187
|
+
if (sidecar.sessionId !== job.sessionId || !Array.isArray(sidecar.chunkEntries)) return null
|
|
188
|
+
return sidecar.chunkEntries
|
|
189
|
+
} catch {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function canonicalFinalizationIsComplete(job: MeetingFinalizationJob): boolean {
|
|
195
|
+
try {
|
|
196
|
+
const sidecar = JSON.parse(readFileSync(job.sidecarPath, 'utf8')) as Record<string, unknown>
|
|
197
|
+
return sidecar.sessionId === job.sessionId && sidecar.finalizationState === 'complete'
|
|
198
|
+
} catch {
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function markCanonicalFinalizationState(
|
|
204
|
+
sidecarPath: string,
|
|
205
|
+
state: MeetingFinalizationJob['phase'] | 'complete',
|
|
206
|
+
claimPending: boolean,
|
|
207
|
+
): void {
|
|
208
|
+
if (!existsSync(sidecarPath)) return
|
|
209
|
+
const parsed = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
|
|
210
|
+
parsed.finalizationState = state
|
|
211
|
+
parsed.claimPending = claimPending
|
|
212
|
+
parsed.finalizationUpdatedAt = new Date().toISOString()
|
|
213
|
+
if (state === 'complete') delete parsed.finalizationError
|
|
214
|
+
durableAtomicWriteFileSync(sidecarPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getMeetingFinalizationSnapshot(): {
|
|
218
|
+
pending: number
|
|
219
|
+
failed: number
|
|
220
|
+
oldestUpdatedAt: string | null
|
|
221
|
+
lastError: string | null
|
|
222
|
+
malformed: number
|
|
223
|
+
} {
|
|
224
|
+
const jobs = new MeetingFinalizationJobStore().list()
|
|
225
|
+
const failed = jobs.filter(job => Boolean(job.lastError))
|
|
226
|
+
const oldest = jobs.map(job => job.updatedAt).sort()[0] ?? null
|
|
227
|
+
const mostRecentFailure = failed.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]
|
|
228
|
+
return {
|
|
229
|
+
pending: jobs.length,
|
|
230
|
+
failed: failed.length,
|
|
231
|
+
oldestUpdatedAt: oldest,
|
|
232
|
+
lastError: mostRecentFailure?.lastError?.slice(0, 200) ?? null,
|
|
233
|
+
malformed: new MeetingFinalizationJobStore().malformedCount(),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -89,6 +89,10 @@ export interface SaveMeetingInput {
|
|
|
89
89
|
chunkEntries?: IndexedTranscriptChunk[]
|
|
90
90
|
providerCandidates?: Record<string, ProviderCandidateRecord>
|
|
91
91
|
transferIntegrity?: TranscriptGapReport | null
|
|
92
|
+
/** Durable intent used to reconstruct post-save work if the process exits
|
|
93
|
+
* between the canonical commit and creation of the replay job. */
|
|
94
|
+
finalizationRequired?: boolean
|
|
95
|
+
claimPending?: boolean
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
export interface SavedMeeting {
|
|
@@ -444,6 +448,11 @@ export class MeetingStore {
|
|
|
444
448
|
transcriptionQuality: 'streaming',
|
|
445
449
|
batchApplied: false,
|
|
446
450
|
streamingWordCount: wordCount(transcript),
|
|
451
|
+
...(input.finalizationRequired ? {
|
|
452
|
+
finalizationState: 'capture_pending',
|
|
453
|
+
claimPending: input.claimPending === true,
|
|
454
|
+
finalizationUpdatedAt: new Date().toISOString(),
|
|
455
|
+
} : {}),
|
|
447
456
|
}
|
|
448
457
|
|
|
449
458
|
// Sidecar first, markdown second: the markdown is the visible commit marker.
|
|
@@ -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) {
|