@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.
- package/CHANGELOG.md +4 -0
- package/package.json +2 -2
- package/server/index.ts +14 -0
- package/server/lib/bookmarks.ts +96 -0
- package/server/lib/handoff-store.ts +404 -0
- package/server/lib/openai-tts-budget.ts +142 -0
- package/server/lib/prompt-edit.ts +224 -0
- package/server/lib/recovery-activity.ts +168 -0
- package/server/lib/speaker-trainer.ts +532 -0
- package/server/lib/tts-cache.ts +596 -0
- package/server/routes/bookmarks.ts +59 -0
- package/server/routes/glossary.ts +153 -0
- package/server/routes/handoffs.ts +101 -0
- package/server/routes/prompt-edit.ts +33 -0
- package/server/routes/recovery.ts +76 -0
- package/server/routes/tts.ts +709 -0
- package/server/routes/voice.ts +317 -0
- package/shared/handoff-intent.ts +90 -0
|
@@ -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
|
+
}
|