@gotcos/glasses-server 6.12.7 → 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,168 @@
1
+ import type { NextFunction, Request, Response } from 'express'
2
+
3
+ export interface RecoveryBusyDetail { kind: string; id: string; ageMs: number }
4
+
5
+ interface ActivityLease {
6
+ kind: string
7
+ startedAt: number
8
+ expiresAt: number | null
9
+ disconnectedAt?: number
10
+ }
11
+
12
+ const active = new Map<string, ActivityLease>()
13
+ let maintenance = false
14
+ let sequence = 0
15
+
16
+ const RECORDING_LEASE_MS = 20_000
17
+ const DISCONNECTED_REQUEST_GRACE_MS = 120_000
18
+
19
+ export type RecoveryRouteClass = 'exempt' | 'request' | 'operation'
20
+
21
+ const EXEMPT_EXACT = new Set([
22
+ 'GET /api/health',
23
+ 'GET /api/live',
24
+ 'GET /api/display-stream',
25
+ 'POST /api/diag/client',
26
+ 'GET /api/diag/health',
27
+ 'GET /api/recovery/status',
28
+ 'POST /api/recovery/whisper/restart',
29
+ 'POST /api/recovery/server/restart',
30
+ ])
31
+
32
+ const OPERATION_GET_PREFIXES = [
33
+ '/api/models',
34
+ '/v1/models',
35
+ '/api/tasks',
36
+ '/api/people',
37
+ '/api/memory',
38
+ '/api/calendar',
39
+ '/api/threads',
40
+ '/api/badges',
41
+ '/api/welcome-context',
42
+ '/api/handoffs',
43
+ '/api/media/',
44
+ '/api/tts/play/',
45
+ ]
46
+
47
+ /**
48
+ * Recovery admission is intentionally fail-safe: every API mutation and every
49
+ * read known to spawn a child, populate a cache, or lazily create media is an
50
+ * operation. Only explicitly inert health/stream/diagnostic routes are exempt.
51
+ */
52
+ export function classifyRecoveryRoute(method: string, path: string): RecoveryRouteClass {
53
+ const verb = method.toUpperCase()
54
+ const normalized = path.split('?')[0]
55
+ if (EXEMPT_EXACT.has(`${verb} ${normalized}`)) return 'exempt'
56
+ if (!normalized.startsWith('/api/') && !normalized.startsWith('/v1/')) return 'exempt'
57
+ if (verb !== 'GET' && verb !== 'HEAD') return 'operation'
58
+ if (OPERATION_GET_PREFIXES.some(prefix => normalized === prefix || normalized.startsWith(prefix))) {
59
+ return 'operation'
60
+ }
61
+ return 'request'
62
+ }
63
+
64
+ function prune(now = Date.now()): void {
65
+ for (const [id, lease] of active) {
66
+ if (lease.expiresAt != null && lease.expiresAt <= now) active.delete(id)
67
+ }
68
+ }
69
+
70
+ function createLease(kind: string, id: string, expiresAt: number | null = null): () => void {
71
+ active.set(id, { kind, startedAt: Date.now(), expiresAt })
72
+ let released = false
73
+ return () => {
74
+ if (released) return
75
+ released = true
76
+ active.delete(id)
77
+ }
78
+ }
79
+
80
+ export function createRecordingLease(kind: string, id: string): void {
81
+ const now = Date.now()
82
+ active.set(`recording:${id}`, { kind, startedAt: now, expiresAt: now + RECORDING_LEASE_MS })
83
+ }
84
+
85
+ export function renewRecordingLease(kind: string, id: string): void {
86
+ createRecordingLease(kind, id)
87
+ }
88
+
89
+ export function releaseRecordingLease(id: string): void {
90
+ active.delete(`recording:${id}`)
91
+ }
92
+
93
+ /** Acquire before the first side-effecting await; the owner releases only after
94
+ * its durable/cache/background work truly settles. */
95
+ export function tryAcquireOperationLease(kind: string, ownerId?: string):
96
+ | { ok: true; id: string; release: () => void }
97
+ | { ok: false; reason: 'maintenance' } {
98
+ if (maintenance) return { ok: false, reason: 'maintenance' }
99
+ const id = ownerId ? `operation:${ownerId}` : `operation:${++sequence}`
100
+ return { ok: true, id, release: createLease(kind, id) }
101
+ }
102
+
103
+ export async function withOperationLease<T>(kind: string, work: () => Promise<T>, ownerId?: string): Promise<T> {
104
+ const lease = tryAcquireOperationLease(kind, ownerId)
105
+ if (!lease.ok) throw Object.assign(new Error('Server recovery in progress'), { code: 'SERVER_MAINTENANCE' })
106
+ try { return await work() } finally { lease.release() }
107
+ }
108
+
109
+ export function recoveryBusyDetails(): RecoveryBusyDetail[] {
110
+ const now = Date.now()
111
+ prune(now)
112
+ return [...active.entries()].map(([id, item]) => ({
113
+ kind: item.kind,
114
+ id: id.startsWith('recording:') ? id.slice('recording:'.length) : id,
115
+ ageMs: now - item.startedAt,
116
+ }))
117
+ }
118
+
119
+ export function acquireMaintenance():
120
+ | { ok: true; release: () => void }
121
+ | { ok: false; busy: RecoveryBusyDetail[]; release: () => void } {
122
+ if (maintenance) {
123
+ return { ok: false, busy: [{ kind: 'maintenance', id: 'active', ageMs: 0 }], release: () => {} }
124
+ }
125
+ // Atomic in Node's event loop: close admission before looking at activity.
126
+ maintenance = true
127
+ let released = false
128
+ const release = () => {
129
+ if (released) return
130
+ released = true
131
+ maintenance = false
132
+ }
133
+ const busy = recoveryBusyDetails()
134
+ if (busy.length > 0) return { ok: false, busy, release }
135
+ return { ok: true, release }
136
+ }
137
+
138
+ export function recoveryAdmissionMiddleware(req: Request, res: Response, next: NextFunction): void {
139
+ const routeClass = classifyRecoveryRoute(req.method, req.originalUrl || req.path)
140
+ if (routeClass === 'exempt') return next()
141
+ if (maintenance) {
142
+ res.status(503).json({ error: 'Server recovery in progress', reason: 'server_maintenance', retryable: true })
143
+ return
144
+ }
145
+
146
+ const id = `${routeClass}:${++sequence}`
147
+ const release = createLease(`${req.method} ${(req.originalUrl || req.path).split('?')[0]}`, id)
148
+ res.once('finish', release)
149
+ res.once('close', () => {
150
+ const lease = active.get(id)
151
+ if (!lease) return
152
+ // Socket close is not operation settlement. Keep a bounded grace lease so
153
+ // a handler/background owner cannot be restarted out from underneath.
154
+ lease.disconnectedAt = Date.now()
155
+ lease.expiresAt = Date.now() + DISCONNECTED_REQUEST_GRACE_MS
156
+ })
157
+ next()
158
+ }
159
+
160
+ export function getRecoveryActivityStatus(): { maintenance: boolean; active: RecoveryBusyDetail[] } {
161
+ return { maintenance, active: recoveryBusyDetails() }
162
+ }
163
+
164
+ export function __resetRecoveryActivityForTests(): void {
165
+ active.clear()
166
+ maintenance = false
167
+ sequence = 0
168
+ }
@@ -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
+ }