@gotcos/glasses-server 6.13.0 → 6.14.1

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,317 @@
1
+ // Voice enrollment, status, and multi-speaker training endpoints
2
+
3
+ import { Router } from 'express'
4
+ import { errMsg } from '../lib/utils.js'
5
+ import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
+ import { resolve } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
9
+ import { statSync } from 'node:fs'
10
+ import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
11
+
12
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
13
+ const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
14
+ const EXT_AUDIO_DIR = resolve(__dirname, '..', 'data', 'ext-audio')
15
+
16
+ export const voiceRouter = Router()
17
+
18
+ // POST /api/voice/enroll — accept WAV audio, extract embedding, store as profile
19
+ voiceRouter.post('/voice/enroll', async (req, res) => {
20
+ try {
21
+ const name = (req.query.name as string) || 'MU'
22
+
23
+ // Collect raw audio body
24
+ const buffers: Buffer[] = []
25
+ for await (const chunk of req) {
26
+ buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
27
+ }
28
+ const audioBuffer = Buffer.concat(buffers)
29
+
30
+ if (audioBuffer.length < 1000) {
31
+ return res.status(400).json({ success: false, error: 'Audio too short — need at least 5 seconds' })
32
+ }
33
+
34
+ const result = enrollSpeaker(name, audioBuffer)
35
+ res.json(result)
36
+ } catch (err: unknown) {
37
+ res.status(500).json({ success: false, error: errMsg(err) })
38
+ }
39
+ })
40
+
41
+ // GET /api/voice/status — is MU enrolled?
42
+ voiceRouter.get('/voice/status', (_req, res) => {
43
+ res.json({
44
+ enrolled: isEnrolled('MU'),
45
+ speakers: getAllSpeakerNames(),
46
+ })
47
+ })
48
+
49
+ // POST /api/voice/identify — one-shot identification (testing)
50
+ voiceRouter.post('/voice/identify', async (req, res) => {
51
+ try {
52
+ const buffers: Buffer[] = []
53
+ for await (const chunk of req) {
54
+ buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
55
+ }
56
+ const audioBuffer = Buffer.concat(buffers)
57
+
58
+ const result = identifySpeaker(audioBuffer)
59
+ res.json(result ?? { speaker: 'Unknown', similarity: 0 })
60
+ } catch (err: unknown) {
61
+ res.status(500).json({ error: errMsg(err) })
62
+ }
63
+ })
64
+
65
+ // POST /api/voice/train — train voiceprints from Fireflies meeting audio
66
+ voiceRouter.post('/voice/train', async (req, res) => {
67
+ try {
68
+ const { speakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
69
+ const report = await trainFromFireflies({
70
+ speakerNames,
71
+ minSegments,
72
+ minSegmentDuration,
73
+ limit,
74
+ maxEmbeddingsPerSpeaker,
75
+ fresh,
76
+ })
77
+ res.json(report)
78
+ } catch (err: unknown) {
79
+ res.status(500).json({ error: errMsg(err) })
80
+ }
81
+ })
82
+
83
+ // GET /api/voice/training-status — show trained speakers and enrollment state
84
+ voiceRouter.get('/voice/training-status', async (_req, res) => {
85
+ try {
86
+ const status = await getTrainingStatus()
87
+ res.json(status)
88
+ } catch (err: unknown) {
89
+ res.status(500).json({ error: errMsg(err) })
90
+ }
91
+ })
92
+
93
+ // POST /api/voice/train-g2 — train from saved G2-mic audio chunks
94
+ // These accumulate during meetings for speakers who need more embeddings
95
+ voiceRouter.post('/voice/train-g2', async (req, res) => {
96
+ try {
97
+ const targetSpeaker = req.body?.speaker as string | undefined
98
+ if (!existsSync(AUDIO_SAVE_DIR)) {
99
+ return res.json({ trained: 0, speakers: [], message: 'No saved G2 audio yet' })
100
+ }
101
+
102
+ const speakerDirs = readdirSync(AUDIO_SAVE_DIR, { withFileTypes: true })
103
+ .filter(d => d.isDirectory())
104
+ .filter(d => !targetSpeaker || d.name === targetSpeaker.replace(/\s+/g, '_'))
105
+
106
+ const results: Array<{ speaker: string; chunks: number; enrolled: number }> = []
107
+
108
+ for (const dir of speakerDirs) {
109
+ const speakerName = dir.name.replace(/_/g, ' ')
110
+ const speakerPath = resolve(AUDIO_SAVE_DIR, dir.name)
111
+ const wavFiles = readdirSync(speakerPath).filter(f => f.endsWith('.wav')).sort()
112
+
113
+ if (wavFiles.length === 0) continue
114
+
115
+ // Extract all embeddings, select most diverse
116
+ const embeddings: Float32Array[] = []
117
+ for (const wav of wavFiles) {
118
+ const buffer = readFileSync(resolve(speakerPath, wav))
119
+ const emb = extractEmbedding(buffer)
120
+ if (emb) embeddings.push(emb)
121
+ }
122
+
123
+ if (embeddings.length === 0) {
124
+ results.push({ speaker: speakerName, chunks: wavFiles.length, enrolled: 0 })
125
+ continue
126
+ }
127
+
128
+ // Enroll diverse embeddings (enrollEmbedding handles diversity gate + FIFO cap)
129
+ let enrolled = 0
130
+ for (const emb of embeddings) {
131
+ const result = enrollEmbedding(speakerName, emb, 'g2-training')
132
+ if (result.success) enrolled++
133
+ }
134
+
135
+ results.push({ speaker: speakerName, chunks: wavFiles.length, enrolled })
136
+
137
+ // Clean up processed audio
138
+ for (const wav of wavFiles) {
139
+ try { unlinkSync(resolve(speakerPath, wav)) } catch {}
140
+ }
141
+ try { rmdirSync(speakerPath) } catch {}
142
+ }
143
+
144
+ const totalEnrolled = results.reduce((sum, r) => sum + r.enrolled, 0)
145
+ res.json({ trained: totalEnrolled, speakers: results })
146
+ } catch (err: unknown) {
147
+ res.status(500).json({ error: errMsg(err) })
148
+ }
149
+ })
150
+
151
+ // GET /api/voice/saved-audio — show accumulated G2 training audio
152
+ voiceRouter.get('/voice/saved-audio', (_req, res) => {
153
+ try {
154
+ if (!existsSync(AUDIO_SAVE_DIR)) {
155
+ return res.json({ speakers: [] })
156
+ }
157
+
158
+ const speakerDirs = readdirSync(AUDIO_SAVE_DIR, { withFileTypes: true })
159
+ .filter(d => d.isDirectory())
160
+
161
+ const speakers = speakerDirs.map(d => {
162
+ const speakerPath = resolve(AUDIO_SAVE_DIR, d.name)
163
+ const wavFiles = readdirSync(speakerPath).filter(f => f.endsWith('.wav'))
164
+ return {
165
+ name: d.name.replace(/_/g, ' '),
166
+ chunks: wavFiles.length,
167
+ currentEmbeddings: getEmbeddingCount(d.name.replace(/_/g, ' ')),
168
+ }
169
+ }).filter(s => s.chunks > 0)
170
+
171
+ res.json({ speakers })
172
+ } catch (err: unknown) {
173
+ res.status(500).json({ error: errMsg(err) })
174
+ }
175
+ })
176
+
177
+ // GET /api/voice/ext-audio — list saved unrecognized speaker audio (72hr retention)
178
+ voiceRouter.get('/voice/ext-audio', (_req, res) => {
179
+ try {
180
+ if (!existsSync(EXT_AUDIO_DIR)) {
181
+ return res.json({ sessions: [], totalChunks: 0 })
182
+ }
183
+
184
+ const sessionDirs = readdirSync(EXT_AUDIO_DIR, { withFileTypes: true })
185
+ .filter(d => d.isDirectory())
186
+
187
+ const sessions = sessionDirs.map(d => {
188
+ const dirPath = resolve(EXT_AUDIO_DIR, d.name)
189
+ const wavFiles = readdirSync(dirPath).filter(f => f.endsWith('.wav')).sort()
190
+ let oldestMs = Date.now(), newestMs = 0
191
+ for (const f of wavFiles) {
192
+ try {
193
+ const { mtimeMs } = statSync(resolve(dirPath, f))
194
+ if (mtimeMs < oldestMs) oldestMs = mtimeMs
195
+ if (mtimeMs > newestMs) newestMs = mtimeMs
196
+ } catch {}
197
+ }
198
+ const ageHours = ((Date.now() - oldestMs) / (60 * 60 * 1000)).toFixed(1)
199
+ return {
200
+ sessionId: d.name,
201
+ chunks: wavFiles.length,
202
+ ageHours: parseFloat(ageHours),
203
+ expiresIn: `${Math.max(0, 72 - parseFloat(ageHours)).toFixed(1)}h`,
204
+ }
205
+ }).filter(s => s.chunks > 0)
206
+
207
+ res.json({
208
+ sessions,
209
+ totalChunks: sessions.reduce((sum, s) => sum + s.chunks, 0),
210
+ })
211
+ } catch (err: unknown) {
212
+ res.status(500).json({ error: errMsg(err) })
213
+ }
214
+ })
215
+
216
+ // POST /api/voice/enroll-ext — enroll saved Ext audio under a speaker name
217
+ // Body: { name: "Chuks", sessionId?: "abc123" }
218
+ // If sessionId provided, only enroll from that session. Otherwise, enroll from all ext sessions.
219
+ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
220
+ try {
221
+ const { name, sessionId } = req.body ?? {}
222
+ if (!name || typeof name !== 'string' || name.length < 2) {
223
+ return res.status(400).json({ error: 'name is required (min 2 chars)' })
224
+ }
225
+
226
+ if (!existsSync(EXT_AUDIO_DIR)) {
227
+ return res.json({ enrolled: 0, message: 'No ext-audio available' })
228
+ }
229
+
230
+ // Collect target directories
231
+ const targetDirs: string[] = []
232
+ if (sessionId) {
233
+ const dirPath = resolve(EXT_AUDIO_DIR, sessionId)
234
+ if (existsSync(dirPath)) targetDirs.push(dirPath)
235
+ else return res.status(404).json({ error: `Session ${sessionId} not found in ext-audio` })
236
+ } else {
237
+ const sessionDirs = readdirSync(EXT_AUDIO_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
238
+ for (const d of sessionDirs) targetDirs.push(resolve(EXT_AUDIO_DIR, d.name))
239
+ }
240
+
241
+ // Extract all embeddings from WAV chunks
242
+ const allEmbeddings: Float32Array[] = []
243
+ let totalChunks = 0
244
+ for (const dirPath of targetDirs) {
245
+ const wavFiles = readdirSync(dirPath).filter(f => f.endsWith('.wav')).sort()
246
+ for (const wav of wavFiles) {
247
+ totalChunks++
248
+ const buffer = readFileSync(resolve(dirPath, wav))
249
+ const emb = extractEmbedding(buffer)
250
+ if (emb) allEmbeddings.push(emb)
251
+ }
252
+ }
253
+
254
+ if (allEmbeddings.length === 0) {
255
+ return res.json({ enrolled: 0, totalChunks, message: 'No valid embeddings extracted from ext audio' })
256
+ }
257
+
258
+ // Greedy diversity selection: pick most diverse embeddings (max 20)
259
+ const maxToEnroll = 20
260
+ const selected = greedyDiversitySelect(allEmbeddings, maxToEnroll)
261
+
262
+ // Enroll selected embeddings
263
+ let enrolled = 0
264
+ for (const emb of selected) {
265
+ const result = enrollEmbedding(name, emb, 'ext-retroactive', true)
266
+ if (result.success) enrolled++
267
+ }
268
+
269
+ // Clean up enrolled ext-audio
270
+ for (const dirPath of targetDirs) {
271
+ try { rmSync(dirPath, { recursive: true, force: true }) } catch {}
272
+ }
273
+
274
+ res.json({
275
+ speaker: name,
276
+ enrolled,
277
+ totalChunks,
278
+ embeddingsExtracted: allEmbeddings.length,
279
+ selectedDiverse: selected.length,
280
+ message: `Enrolled ${enrolled} diverse embeddings for ${name} from ${totalChunks} ext audio chunks`,
281
+ })
282
+ } catch (err: unknown) {
283
+ res.status(500).json({ error: errMsg(err) })
284
+ }
285
+ })
286
+
287
+ /** Greedy diversity selection — pick N most acoustically diverse embeddings */
288
+ function greedyDiversitySelect(embeddings: Float32Array[], maxN: number): Float32Array[] {
289
+ if (embeddings.length <= maxN) return embeddings
290
+
291
+ // Find the most dissimilar pair as seeds
292
+ let maxDist = -1, seedA = 0, seedB = 1
293
+ for (let i = 0; i < embeddings.length; i++) {
294
+ for (let j = i + 1; j < embeddings.length; j++) {
295
+ const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[j])
296
+ if (dist > maxDist) { maxDist = dist; seedA = i; seedB = j }
297
+ }
298
+ }
299
+
300
+ const selected = new Set([seedA, seedB])
301
+ while (selected.size < maxN) {
302
+ let bestIdx = -1, bestMinDist = -1
303
+ for (let i = 0; i < embeddings.length; i++) {
304
+ if (selected.has(i)) continue
305
+ let minDist = Infinity
306
+ for (const s of selected) {
307
+ const dist = 1 - rawCosineSimilarity(embeddings[i], embeddings[s])
308
+ if (dist < minDist) minDist = dist
309
+ }
310
+ if (minDist > bestMinDist) { bestMinDist = minDist; bestIdx = i }
311
+ }
312
+ if (bestIdx === -1) break
313
+ selected.add(bestIdx)
314
+ }
315
+
316
+ return [...selected].map(i => embeddings[i])
317
+ }
@@ -0,0 +1,90 @@
1
+ export const HANDOFF_CODE_LENGTH = 8
2
+ export const HANDOFF_CODE_PATTERN = /^[0-9A-HJKMNP-TV-Z]{8}$/
3
+
4
+ const CODE_CHARS = /[0-9A-Z]/g
5
+
6
+ function normalizeVoiceCommand(text: string): string {
7
+ return text
8
+ .trim()
9
+ .replace(/[.,!?;:]+/g, ' ')
10
+ .replace(/\s+/g, ' ')
11
+ .trim()
12
+ }
13
+
14
+ export interface HandoffPickupIntent {
15
+ kind: 'latest' | 'code'
16
+ code?: string
17
+ followup: string
18
+ }
19
+
20
+ export interface HandoffCreateIntent {
21
+ target: 'desktop' | 'g2' | 'codex' | 'claude'
22
+ }
23
+
24
+ export function normalizeHandoffCode(input: string): string | null {
25
+ const raw = input
26
+ .toUpperCase()
27
+ .replace(/O/g, '0')
28
+ .replace(/[IL]/g, '1')
29
+ .match(CODE_CHARS)
30
+ ?.join('') ?? ''
31
+ if (raw.length !== HANDOFF_CODE_LENGTH) return null
32
+ if (!HANDOFF_CODE_PATTERN.test(raw)) return null
33
+ return raw
34
+ }
35
+
36
+ export function parseHandoffPickupIntent(text: string): HandoffPickupIntent | null {
37
+ const trimmed = normalizeVoiceCommand(text)
38
+ if (!trimmed) return null
39
+
40
+ const latest = trimmed.match(/^(?:pick\s*up|pickup|resume|continue)\s+(?:where\s+i\s+left\s+off|latest|last\s+handoff|my\s+handoff)(?:\s+(.*))?$/i)
41
+ if (latest) {
42
+ return {
43
+ kind: 'latest',
44
+ followup: (latest[1] ?? '').replace(/^(?:and|then|:)\s+/i, '').trim(),
45
+ }
46
+ }
47
+
48
+ const coded = trimmed.match(/^(?:pick\s*up|pickup|resume|continue)(?:\s+(handoff|code))?\s+(.+)$/i)
49
+ if (!coded) return null
50
+ const explicitCodeWord = !!coded[1]
51
+ const rest = coded[2] ?? ''
52
+ let candidate = ''
53
+ let consumed = 0
54
+ for (let i = 0; i < rest.length; i++) {
55
+ const ch = rest[i]
56
+ if (/[0-9A-Z]/i.test(ch)) {
57
+ candidate += ch
58
+ if (candidate.length === HANDOFF_CODE_LENGTH) {
59
+ consumed = i + 1
60
+ break
61
+ }
62
+ } else if (candidate.length > 0 && !/[\s-]/.test(ch)) {
63
+ break
64
+ }
65
+ }
66
+ const rawCandidate = rest.slice(0, consumed).trim()
67
+ const isDisplayedBareCode = rawCandidate === rawCandidate.toUpperCase()
68
+ && rawCandidate.replace(/[\s-]/g, '').length === HANDOFF_CODE_LENGTH
69
+ if (!explicitCodeWord && !/\d/.test(candidate) && !isDisplayedBareCode) return null
70
+ const code = normalizeHandoffCode(candidate)
71
+ if (!code) return null
72
+
73
+ return {
74
+ kind: 'code',
75
+ code,
76
+ followup: rest.slice(consumed).trim().replace(/^(?:and|then|:)\s+/i, '').trim(),
77
+ }
78
+ }
79
+
80
+ export function parseHandoffCreateIntent(text: string): HandoffCreateIntent | null {
81
+ const match = normalizeVoiceCommand(text).match(/^(?:handoff|hand[-\s]+off|pass|send)\s+(?:this|this\s+work|current\s+work|current\s+chat|current\s+message)(?:\s+(?:to|over\s+to)(?:\s+my)?\s+(desktop|g2|codex|claude(?:\s+code)?|mac|laptop))?$/i)
82
+ if (!match) return null
83
+ const rawTarget = match[1]?.toLowerCase()
84
+ const target = rawTarget?.startsWith('claude')
85
+ ? 'claude'
86
+ : rawTarget === 'mac' || rawTarget === 'laptop'
87
+ ? 'desktop'
88
+ : (rawTarget ?? 'desktop') as HandoffCreateIntent['target']
89
+ return { target }
90
+ }