@gotcos/glasses-server 6.13.0 → 6.14.0

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.
@@ -0,0 +1,532 @@
1
+ // Multi-speaker voiceprint training from Fireflies meeting transcripts.
2
+ // Downloads meeting audio, extracts per-speaker segments via ffmpeg,
3
+ // and builds diverse embedding profiles through greedy diversity selection.
4
+ //
5
+ // Deep training: extracts ALL candidate embeddings, then selects the N most
6
+ // acoustically diverse ones (maximizing pairwise cosine distance).
7
+
8
+ import { resolve } from 'node:path'
9
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
10
+ import { execSync } from 'node:child_process'
11
+ import {
12
+ enrollEmbedding, extractEmbedding, isEmbeddingAvailable, getAllSpeakerNames,
13
+ rawCosineSimilarity, saveProfileStore, rebuildAllProfiles, clearSpeakerEmbeddings,
14
+ } from './speaker-embeddings.js'
15
+ import { COS_SCRIPTS_DIR } from './python-bridge.js'
16
+
17
+ const CACHE_DIR = '/tmp/cos-speaker-training'
18
+ const PROGRESS_PATH = resolve(CACHE_DIR, 'training-progress.json')
19
+
20
+ // Fireflies GraphQL API
21
+ const FIREFLIES_API = 'https://api.fireflies.ai/graphql'
22
+
23
+ // Load Fireflies API key from COS .env (not the glasses app .env — that gets published)
24
+ function loadCosEnvKey(key: string): string | undefined {
25
+ if (process.env[key]) return process.env[key]
26
+ if (!COS_SCRIPTS_DIR) return undefined
27
+ const envPaths = [
28
+ resolve(COS_SCRIPTS_DIR, '.env'),
29
+ resolve(COS_SCRIPTS_DIR, '../../.env'),
30
+ ]
31
+ for (const envPath of envPaths) {
32
+ if (!existsSync(envPath)) continue
33
+ try {
34
+ const content = readFileSync(envPath, 'utf-8')
35
+ for (const line of content.split('\n')) {
36
+ const match = line.match(new RegExp(`^${key}=(.+)$`))
37
+ if (match) return match[1].trim()
38
+ }
39
+ } catch { /* skip */ }
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ interface FirefliesSentence {
45
+ speaker_name: string
46
+ start_time: number
47
+ end_time: number
48
+ text: string
49
+ }
50
+
51
+ interface FirefliesTranscript {
52
+ id: string
53
+ title: string
54
+ audio_url: string | null
55
+ sentences: FirefliesSentence[]
56
+ date: number
57
+ }
58
+
59
+ interface TrainingProgress {
60
+ processedMeetings: string[]
61
+ speakerSegmentCounts: Record<string, number>
62
+ lastRunAt: string
63
+ }
64
+
65
+ export interface TrainingReport {
66
+ speakersProcessed: number
67
+ segmentsExtracted: number
68
+ enrollmentsAdded: number
69
+ skippedDuplicate: number
70
+ errors: string[]
71
+ speakers: Array<{ name: string; segments: number; enrolled: boolean; embeddings: number }>
72
+ }
73
+
74
+ export interface TrainingStatus {
75
+ speakers: Array<{ name: string; segments: number; meetings: number; enrolled: boolean }>
76
+ lastTrainedAt: string | null
77
+ }
78
+
79
+ function getApiKey(): string {
80
+ const key = loadCosEnvKey('FIREFLIES_API_KEY')
81
+ if (!key) throw new Error('FIREFLIES_API_KEY not found — set it in MU-Chief-Staff/.env')
82
+ return key
83
+ }
84
+
85
+ function ensureFfmpeg(): void {
86
+ try {
87
+ execSync('ffmpeg -version', { stdio: 'pipe' })
88
+ } catch {
89
+ throw new Error('ffmpeg not found — install with: brew install ffmpeg')
90
+ }
91
+ }
92
+
93
+ function ensureCacheDir(): void {
94
+ if (!existsSync(CACHE_DIR)) {
95
+ mkdirSync(CACHE_DIR, { recursive: true })
96
+ }
97
+ }
98
+
99
+ function loadProgress(): TrainingProgress {
100
+ if (existsSync(PROGRESS_PATH)) {
101
+ return JSON.parse(readFileSync(PROGRESS_PATH, 'utf-8'))
102
+ }
103
+ return { processedMeetings: [], speakerSegmentCounts: {}, lastRunAt: '' }
104
+ }
105
+
106
+ function saveProgress(progress: TrainingProgress): void {
107
+ writeFileSync(PROGRESS_PATH, JSON.stringify(progress, null, 2))
108
+ }
109
+
110
+ /** Fetch transcripts from Fireflies GraphQL API with pagination */
111
+ async function fetchFirefliesTranscripts(limit: number): Promise<FirefliesTranscript[]> {
112
+ const apiKey = getApiKey()
113
+ const results: FirefliesTranscript[] = []
114
+ let skip = 0
115
+ const batchSize = 50
116
+
117
+ while (results.length < limit) {
118
+ const query = `
119
+ query {
120
+ transcripts(limit: ${batchSize}, skip: ${skip}) {
121
+ id
122
+ title
123
+ audio_url
124
+ date
125
+ sentences {
126
+ speaker_name
127
+ start_time
128
+ end_time
129
+ text
130
+ }
131
+ }
132
+ }
133
+ `
134
+
135
+ const res = await fetch(FIREFLIES_API, {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'Authorization': `Bearer ${apiKey}`,
140
+ },
141
+ body: JSON.stringify({ query }),
142
+ })
143
+
144
+ if (!res.ok) {
145
+ if (res.status === 429) {
146
+ console.log('[speaker-trainer] Rate limited, waiting 10s...')
147
+ await new Promise(r => setTimeout(r, 10_000))
148
+ continue
149
+ }
150
+ throw new Error(`Fireflies API error: ${res.status} ${res.statusText}`)
151
+ }
152
+
153
+ const data = await res.json() as any
154
+ const transcripts = data?.data?.transcripts ?? []
155
+ if (transcripts.length === 0) break
156
+
157
+ for (const t of transcripts) {
158
+ if (results.length >= limit) break
159
+ results.push(t)
160
+ }
161
+
162
+ skip += batchSize
163
+ if (results.length < limit) {
164
+ await new Promise(r => setTimeout(r, 500))
165
+ }
166
+ }
167
+
168
+ return results
169
+ }
170
+
171
+ /** Download MP3 audio from Fireflies (URL expires in 24hr) */
172
+ async function downloadAudio(audioUrl: string, meetingId: string): Promise<string> {
173
+ const outPath = resolve(CACHE_DIR, `${meetingId}.mp3`)
174
+ if (existsSync(outPath)) return outPath
175
+
176
+ console.log(`[speaker-trainer] Downloading audio for ${meetingId}...`)
177
+ const res = await fetch(audioUrl)
178
+ if (!res.ok) throw new Error(`Audio download failed: ${res.status}`)
179
+
180
+ const arrayBuffer = await res.arrayBuffer()
181
+ writeFileSync(outPath, Buffer.from(arrayBuffer))
182
+ return outPath
183
+ }
184
+
185
+ /** Extract a WAV segment from an MP3 file using ffmpeg */
186
+ function extractSegment(mp3Path: string, startSec: number, endSec: number, outPath: string): boolean {
187
+ try {
188
+ execSync(
189
+ `ffmpeg -y -i "${mp3Path}" -ss ${startSec} -to ${endSec} -ar 16000 -ac 1 -f wav "${outPath}"`,
190
+ { stdio: 'pipe', timeout: 30_000 }
191
+ )
192
+ return true
193
+ } catch {
194
+ return false
195
+ }
196
+ }
197
+
198
+ /** Find monologue segments (speaker talking for >= minDuration seconds continuously) */
199
+ function findMonologues(
200
+ sentences: FirefliesSentence[],
201
+ speakerName: string,
202
+ minDuration: number
203
+ ): Array<{ start: number; end: number; meetingId?: string }> {
204
+ const segments: Array<{ start: number; end: number }> = []
205
+ let currentStart = -1
206
+ let currentEnd = -1
207
+
208
+ for (const s of sentences) {
209
+ if (s.speaker_name !== speakerName) {
210
+ if (currentStart >= 0 && (currentEnd - currentStart) >= minDuration) {
211
+ segments.push({ start: currentStart, end: currentEnd })
212
+ }
213
+ currentStart = -1
214
+ currentEnd = -1
215
+ continue
216
+ }
217
+ if (currentStart < 0) {
218
+ currentStart = s.start_time
219
+ currentEnd = s.end_time
220
+ } else if (s.start_time - currentEnd < 1.5) {
221
+ currentEnd = s.end_time
222
+ } else {
223
+ if ((currentEnd - currentStart) >= minDuration) {
224
+ segments.push({ start: currentStart, end: currentEnd })
225
+ }
226
+ currentStart = s.start_time
227
+ currentEnd = s.end_time
228
+ }
229
+ }
230
+ if (currentStart >= 0 && (currentEnd - currentStart) >= minDuration) {
231
+ segments.push({ start: currentStart, end: currentEnd })
232
+ }
233
+ return segments
234
+ }
235
+
236
+ /** Normalize speaker names */
237
+ function normalizeSpeakerName(name: string): string | null {
238
+ if (!name || name.trim().length === 0) return null
239
+ if (/^speaker\s*\d*$/i.test(name.trim())) return null
240
+ if (/^unknown$/i.test(name.trim())) return null
241
+ if (/^unidentified$/i.test(name.trim())) return null
242
+ return name.trim()
243
+ .split(' ')
244
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
245
+ .join(' ')
246
+ }
247
+
248
+ /** Greedy diversity selection: pick N most diverse embeddings from a pool.
249
+ * Starts with the pair having maximum distance, then greedily adds the
250
+ * embedding with the highest minimum distance to the selected set. */
251
+ function selectDiverseEmbeddings(
252
+ embeddings: Float32Array[],
253
+ maxCount: number,
254
+ ): Float32Array[] {
255
+ if (embeddings.length <= maxCount) return embeddings
256
+
257
+ // Compute pairwise similarities
258
+ const n = embeddings.length
259
+ const sims: number[][] = Array.from({ length: n }, () => new Array(n).fill(0))
260
+ for (let i = 0; i < n; i++) {
261
+ for (let j = i + 1; j < n; j++) {
262
+ const s = rawCosineSimilarity(embeddings[i], embeddings[j])
263
+ sims[i][j] = s
264
+ sims[j][i] = s
265
+ }
266
+ }
267
+
268
+ // Start with the pair having minimum similarity (maximum diversity)
269
+ let bestPairSim = 1.0
270
+ let bestI = 0, bestJ = 1
271
+ for (let i = 0; i < n; i++) {
272
+ for (let j = i + 1; j < n; j++) {
273
+ if (sims[i][j] < bestPairSim) {
274
+ bestPairSim = sims[i][j]
275
+ bestI = i
276
+ bestJ = j
277
+ }
278
+ }
279
+ }
280
+
281
+ const selected = new Set<number>([bestI, bestJ])
282
+
283
+ // Greedily add embeddings that maximize minimum distance to selected set
284
+ while (selected.size < maxCount && selected.size < n) {
285
+ let bestIdx = -1
286
+ let bestMinDist = -1
287
+
288
+ for (let i = 0; i < n; i++) {
289
+ if (selected.has(i)) continue
290
+ // Find minimum similarity (maximum distance) to any selected embedding
291
+ let minDist = 1.0
292
+ for (const s of selected) {
293
+ minDist = Math.min(minDist, 1 - sims[i][s]) // distance = 1 - similarity
294
+ }
295
+ if (minDist > bestMinDist) {
296
+ bestMinDist = minDist
297
+ bestIdx = i
298
+ }
299
+ }
300
+
301
+ if (bestIdx >= 0) {
302
+ selected.add(bestIdx)
303
+ } else {
304
+ break
305
+ }
306
+ }
307
+
308
+ return Array.from(selected).sort().map(i => embeddings[i])
309
+ }
310
+
311
+ /** Deep training pipeline: extract ALL candidate embeddings, select most diverse 10 */
312
+ export async function trainFromFireflies(options: {
313
+ speakerNames?: string[]
314
+ minSegments?: number
315
+ minSegmentDuration?: number
316
+ limit?: number
317
+ maxEmbeddingsPerSpeaker?: number
318
+ fresh?: boolean // ignore processedMeetings, rebuild from scratch
319
+ } = {}): Promise<TrainingReport> {
320
+ const {
321
+ speakerNames,
322
+ minSegments = 3,
323
+ minSegmentDuration = 5,
324
+ limit = 200,
325
+ maxEmbeddingsPerSpeaker = 10,
326
+ fresh = false,
327
+ } = options
328
+
329
+ if (!isEmbeddingAvailable()) {
330
+ throw new Error('Speaker embedding system not initialized — check sherpa-onnx model')
331
+ }
332
+
333
+ ensureFfmpeg()
334
+ ensureCacheDir()
335
+
336
+ const progress = fresh ? { processedMeetings: [], speakerSegmentCounts: {}, lastRunAt: '' } : loadProgress()
337
+ const report: TrainingReport = {
338
+ speakersProcessed: 0,
339
+ segmentsExtracted: 0,
340
+ enrollmentsAdded: 0,
341
+ skippedDuplicate: 0,
342
+ errors: [],
343
+ speakers: [],
344
+ }
345
+
346
+ // Fresh mode: clear existing embeddings for target speakers so dedup gate doesn't reject new diverse set
347
+ if (fresh && speakerNames && speakerNames.length > 0) {
348
+ for (const name of speakerNames) {
349
+ const cleared = clearSpeakerEmbeddings(name)
350
+ if (cleared) console.log(`[speaker-trainer] Fresh mode: cleared embeddings for "${name}"`)
351
+ }
352
+ }
353
+
354
+ console.log(`[speaker-trainer] Fetching up to ${limit} meetings from Fireflies (fresh: ${fresh})...`)
355
+ const transcripts = await fetchFirefliesTranscripts(limit)
356
+ console.log(`[speaker-trainer] Got ${transcripts.length} transcripts`)
357
+
358
+ // Collect all speakers and their meeting segments
359
+ const speakerMeetings: Map<string, Array<{
360
+ meetingId: string
361
+ audioUrl: string | null
362
+ segments: Array<{ start: number; end: number }>
363
+ }>> = new Map()
364
+
365
+ for (const transcript of transcripts) {
366
+ if (!fresh && progress.processedMeetings.includes(transcript.id)) continue
367
+ if (!transcript.audio_url) continue
368
+ if (!transcript.sentences?.length) continue
369
+
370
+ const speakers = new Set<string>()
371
+ for (const s of transcript.sentences) {
372
+ const normalized = normalizeSpeakerName(s.speaker_name)
373
+ if (normalized) speakers.add(normalized)
374
+ }
375
+
376
+ for (const speaker of speakers) {
377
+ if (speakerNames && !speakerNames.some(n =>
378
+ speaker.toLowerCase().includes(n.toLowerCase()) ||
379
+ n.toLowerCase().includes(speaker.toLowerCase())
380
+ )) continue
381
+
382
+ const monologues = findMonologues(transcript.sentences, speaker, minSegmentDuration)
383
+ if (monologues.length === 0) continue
384
+
385
+ if (!speakerMeetings.has(speaker)) {
386
+ speakerMeetings.set(speaker, [])
387
+ }
388
+ speakerMeetings.get(speaker)!.push({
389
+ meetingId: transcript.id,
390
+ audioUrl: transcript.audio_url,
391
+ segments: monologues,
392
+ })
393
+ }
394
+ }
395
+
396
+ console.log(`[speaker-trainer] Found ${speakerMeetings.size} speakers with monologue segments`)
397
+
398
+ // For deep training: collect ALL embeddings per speaker, then select diverse set
399
+ for (const [speaker, meetings] of speakerMeetings) {
400
+ const totalSegments = meetings.reduce((sum, m) => sum + m.segments.length, 0)
401
+ const uniqueMeetings = new Set(meetings.map(m => m.meetingId)).size
402
+
403
+ if (totalSegments < minSegments) {
404
+ console.log(`[speaker-trainer] Skipping ${speaker}: ${totalSegments} segments (need ${minSegments}+)`)
405
+ continue
406
+ }
407
+
408
+ console.log(`[speaker-trainer] Processing ${speaker}: ${totalSegments} segments in ${uniqueMeetings} meetings`)
409
+ report.speakersProcessed++
410
+
411
+ // Phase 1: Extract ALL candidate embeddings from diverse meetings
412
+ const candidateEmbeddings: Float32Array[] = []
413
+ const meetingIds = new Set<string>()
414
+
415
+ // Spread across meetings for acoustic diversity — take 3 segments per meeting max
416
+ for (const meeting of meetings.slice(0, 20)) {
417
+ if (!meeting.audioUrl) continue
418
+
419
+ let mp3Path: string
420
+ try {
421
+ mp3Path = await downloadAudio(meeting.audioUrl, meeting.meetingId)
422
+ } catch (err: any) {
423
+ report.errors.push(`Download failed for ${meeting.meetingId}: ${err.message}`)
424
+ continue
425
+ }
426
+
427
+ // Take up to 3 segments per meeting (spread across the recording)
428
+ const segs = meeting.segments
429
+ const selectedSegs = segs.length <= 3 ? segs : [
430
+ segs[0],
431
+ segs[Math.floor(segs.length / 2)],
432
+ segs[segs.length - 1],
433
+ ]
434
+
435
+ for (const seg of selectedSegs) {
436
+ const wavPath = resolve(CACHE_DIR, `${meeting.meetingId}_${speaker.replace(/\s+/g, '_')}_${Math.round(seg.start)}.wav`)
437
+
438
+ if (!extractSegment(mp3Path, seg.start, seg.end, wavPath)) {
439
+ continue
440
+ }
441
+
442
+ const wavBuffer = readFileSync(wavPath)
443
+ if (wavBuffer.length < 1000) {
444
+ try { unlinkSync(wavPath) } catch {}
445
+ continue
446
+ }
447
+
448
+ // Extract raw embedding (don't enroll yet)
449
+ const embedding = extractEmbedding(wavBuffer)
450
+ if (embedding) {
451
+ candidateEmbeddings.push(embedding)
452
+ meetingIds.add(meeting.meetingId)
453
+ report.segmentsExtracted++
454
+ }
455
+
456
+ try { unlinkSync(wavPath) } catch {}
457
+ }
458
+
459
+ // Clean up MP3
460
+ try { unlinkSync(mp3Path) } catch {}
461
+
462
+ if (!progress.processedMeetings.includes(meeting.meetingId)) {
463
+ progress.processedMeetings.push(meeting.meetingId)
464
+ }
465
+ }
466
+
467
+ console.log(`[speaker-trainer] ${speaker}: ${candidateEmbeddings.length} candidate embeddings from ${meetingIds.size} meetings`)
468
+
469
+ if (candidateEmbeddings.length === 0) {
470
+ report.speakers.push({ name: speaker, segments: 0, enrolled: false, embeddings: 0 })
471
+ continue
472
+ }
473
+
474
+ // Phase 2: Select most diverse embeddings via greedy algorithm
475
+ const diverse = selectDiverseEmbeddings(candidateEmbeddings, maxEmbeddingsPerSpeaker)
476
+ console.log(`[speaker-trainer] ${speaker}: selected ${diverse.length} most diverse embeddings`)
477
+
478
+ // Phase 3: Enroll the diverse set (skip dedup — diversity selector already handled it)
479
+ let enrolled = 0
480
+ for (const embedding of diverse) {
481
+ const result = enrollEmbedding(speaker, embedding, 'fireflies', true)
482
+ if (result.success) {
483
+ enrolled++
484
+ report.enrollmentsAdded++
485
+ } else if (result.error?.includes('Too similar')) {
486
+ report.skippedDuplicate++
487
+ } else {
488
+ report.errors.push(`${speaker}: ${result.error}`)
489
+ }
490
+ }
491
+
492
+ report.speakers.push({ name: speaker, segments: candidateEmbeddings.length, enrolled: enrolled > 0, embeddings: enrolled })
493
+ progress.speakerSegmentCounts[speaker] = enrolled
494
+
495
+ console.log(`[speaker-trainer] ${speaker}: enrolled ${enrolled}/${diverse.length} diverse embeddings`)
496
+ }
497
+
498
+ progress.lastRunAt = new Date().toISOString()
499
+ saveProgress(progress)
500
+
501
+ console.log(`[speaker-trainer] Done: ${report.enrollmentsAdded} enrollments, ${report.skippedDuplicate} duplicates, ${report.errors.length} errors`)
502
+
503
+ return report
504
+ }
505
+
506
+ /** Get current training status */
507
+ export async function getTrainingStatus(): Promise<TrainingStatus> {
508
+ const progress = loadProgress()
509
+ const enrolledNames = getAllSpeakerNames()
510
+
511
+ const speakers: TrainingStatus['speakers'] = []
512
+
513
+ for (const [name, count] of Object.entries(progress.speakerSegmentCounts)) {
514
+ speakers.push({
515
+ name,
516
+ segments: count,
517
+ meetings: 0,
518
+ enrolled: enrolledNames.includes(name),
519
+ })
520
+ }
521
+
522
+ for (const name of enrolledNames) {
523
+ if (!speakers.some(s => s.name === name)) {
524
+ speakers.push({ name, segments: 0, meetings: 0, enrolled: true })
525
+ }
526
+ }
527
+
528
+ return {
529
+ speakers,
530
+ lastTrainedAt: progress.lastRunAt || null,
531
+ }
532
+ }