@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
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
|