@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.
@@ -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 { fileURLToPath } from 'node:url'
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
- const __dirname = fileURLToPath(new URL('.', import.meta.url))
14
- const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
15
- const EXT_AUDIO_DIR = resolve(__dirname, '..', 'data', 'ext-audio')
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
- const speakerDirs = readdirSync(AUDIO_SAVE_DIR, { withFileTypes: true })
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
- const results: Array<{ speaker: string; chunks: number; enrolled: number }> = []
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 embeddings (enrollEmbedding handles diversity gate + FIFO cap)
220
+ // Enroll the diverse subset (enrollEmbedding handles dedup gate + FIFO cap)
135
221
  let enrolled = 0
136
- for (const emb of embeddings) {
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({ speaker: speakerName, chunks: wavFiles.length, enrolled })
142
-
143
- // Clean up processed audio
144
- for (const wav of wavFiles) {
145
- try { unlinkSync(resolve(speakerPath, wav)) } catch {}
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 = resolve(EXT_AUDIO_DIR, sessionId)
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