@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.
- package/CHANGELOG.md +167 -0
- package/package.json +1 -1
- package/server/lib/chunk-embedding-store.ts +260 -0
- package/server/lib/embedding-eviction.ts +133 -0
- package/server/lib/meeting-audio-archive.ts +314 -0
- package/server/lib/meeting-corrections.ts +218 -0
- package/server/lib/meeting-relabel.ts +279 -0
- package/server/lib/meeting-speaker-review.ts +223 -6
- package/server/lib/speaker-embeddings.ts +63 -9
- package/server/lib/training-audio-provenance.ts +70 -0
- package/server/lib/voice-profile-store.ts +36 -1
- package/server/routes/health.ts +19 -1
- package/server/routes/meeting.ts +508 -2
- package/server/routes/transcribe-stream.ts +59 -0
- package/server/routes/voice.ts +91 -2
package/server/routes/voice.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|