@gotcos/glasses-server 6.21.14 → 6.21.18

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.
@@ -10,6 +10,7 @@ import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js
10
10
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
11
11
  import { dataPath } from '../lib/data-dir.js'
12
12
  import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
13
+ import { trainingSourceFor } from '../lib/training-audio-provenance.js'
13
14
 
14
15
  // These MUST match the writer in transcribe-stream.ts, which saves under
15
16
  // dataPath(). They previously resolved relative to __dirname — i.e. inside the
@@ -192,10 +193,14 @@ voiceRouter.post('/voice/train-g2', async (req, res) => {
192
193
 
193
194
  // Extract all embeddings, select most diverse
194
195
  const embeddings: Float32Array[] = []
196
+ // Parallel to `embeddings`: the WAV each one came from, so the enrollment
197
+ // below can stamp WHICH MEETING produced the sample. Without it a later
198
+ // de-attribution cannot retract this meeting's contribution to the profile.
199
+ const embeddingFiles: string[] = []
195
200
  for (const wav of wavFiles) {
196
201
  const buffer = readFileSync(resolve(speakerPath, wav))
197
202
  const emb = extractEmbedding(buffer)
198
- if (emb) embeddings.push(emb)
203
+ if (emb) { embeddings.push(emb); embeddingFiles.push(wav) }
199
204
  }
200
205
 
201
206
  if (embeddings.length === 0) {
@@ -220,7 +225,13 @@ voiceRouter.post('/voice/train-g2', async (req, res) => {
220
225
  // Enroll the diverse subset (enrollEmbedding handles dedup gate + FIFO cap)
221
226
  let enrolled = 0
222
227
  for (const emb of selected) {
223
- const result = enrollEmbedding(speakerName, emb, 'g2-training')
228
+ // Reference identity, NOT a re-selection: greedyDiversitySelect returns
229
+ // the very same Float32Array objects, so indexOf recovers the filename
230
+ // without changing which samples were chosen. Swapping the selector for
231
+ // an index-returning one would silently alter that choice.
232
+ const at = embeddings.indexOf(emb)
233
+ const source = at >= 0 ? trainingSourceFor(embeddingFiles[at]) : 'g2-training'
234
+ const result = enrollEmbedding(speakerName, emb, source)
224
235
  if (result.success) enrolled++
225
236
  }
226
237
 
@@ -407,6 +418,84 @@ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
407
418
  }
408
419
  })
409
420
 
421
+ // ── Voice sample playback (6.21.18) ───────────────────────────────────────
422
+ //
423
+ // Miles: hearing three seconds of a voice settles an identity question that a
424
+ // similarity score cannot. These two paths need NO retention change — the audio
425
+ // already exists:
426
+ //
427
+ // training-audio what the system thinks a NAMED person sounds like
428
+ // ext-audio an UNIDENTIFIED voice, 72-hour window
429
+ //
430
+ // The first is the higher-value one for the review panel: "is this really Navaz?"
431
+ // is answered by playing Navaz's own profile sample, not by playing the segment
432
+ // under review.
433
+
434
+ /** Newest WAV in a directory — the most representative recent sample. */
435
+ function newestWav(dirPath: string): string | null {
436
+ try {
437
+ const wavs = readdirSync(dirPath).filter(f => f.endsWith('.wav'))
438
+ if (wavs.length === 0) return null
439
+ let best = wavs[0], bestAt = 0
440
+ for (const w of wavs) {
441
+ try {
442
+ const at = statSync(resolve(dirPath, w)).mtimeMs
443
+ if (at >= bestAt) { bestAt = at; best = w }
444
+ } catch { /* skip unreadable */ }
445
+ }
446
+ return resolve(dirPath, best)
447
+ } catch {
448
+ return null
449
+ }
450
+ }
451
+
452
+ // GET /api/voice/profiles/:name/sample — hear what a stored profile sounds like.
453
+ voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
454
+ res.set('Cache-Control', 'private, no-store')
455
+ const name = String(req.params.name ?? '')
456
+ const dirPath = speakerDirPath(AUDIO_SAVE_DIR, name)
457
+ if (!dirPath) {
458
+ res.status(400).json({ error: 'Invalid speaker name', reason: 'invalid_speaker' })
459
+ return
460
+ }
461
+ const wav = existsSync(dirPath) ? newestWav(dirPath) : null
462
+ if (!wav) {
463
+ // A profile can exist with no retained audio: it may have been built from
464
+ // Fireflies seeds, or its training audio may have aged out. Say which rather
465
+ // than implying the person is unknown.
466
+ res.status(404).json({
467
+ error: `No retained audio for "${name}"`,
468
+ reason: 'no_sample_audio',
469
+ enrolled: isEnrolled(name),
470
+ embeddings: getEmbeddingCount(name),
471
+ })
472
+ return
473
+ }
474
+ res.type('audio/wav')
475
+ res.sendFile(wav)
476
+ })
477
+
478
+ // GET /api/voice/ext-audio/:sessionId/sample — hear an unidentified voice.
479
+ voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
480
+ res.set('Cache-Control', 'private, no-store')
481
+ const sessionId = String(req.params.sessionId ?? '')
482
+ const dirPath = speakerDirPath(EXT_AUDIO_DIR, sessionId)
483
+ if (!dirPath) {
484
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
485
+ return
486
+ }
487
+ const wav = existsSync(dirPath) ? newestWav(dirPath) : null
488
+ if (!wav) {
489
+ res.status(404).json({
490
+ error: 'No ext-audio retained for this session',
491
+ reason: 'no_ext_audio',
492
+ })
493
+ return
494
+ }
495
+ res.type('audio/wav')
496
+ res.sendFile(wav)
497
+ })
498
+
410
499
  // GET /api/voice/profiles — enrolled people with sample counts and provenance.
411
500
  // The review surfaces need to see the store; until now the only window into it
412
501
  // was a per-name count, so a misattributed profile was invisible.