@gotcos/glasses-server 6.1.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/.cos-profile.example.json +7 -0
- package/.env.example +44 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/cli.cjs +203 -0
- package/package.json +53 -0
- package/server/env.ts +26 -0
- package/server/index.ts +211 -0
- package/server/lib/archive-budget.ts +65 -0
- package/server/lib/archive.ts +414 -0
- package/server/lib/atomic-fs.ts +50 -0
- package/server/lib/audio-enhance.ts +87 -0
- package/server/lib/claude-bridge.ts +682 -0
- package/server/lib/claude-circuit.ts +52 -0
- package/server/lib/claude-run-ledger.ts +279 -0
- package/server/lib/codex-bridge.ts +476 -0
- package/server/lib/codex-engine-sessions.ts +140 -0
- package/server/lib/codex-run-ledger.ts +298 -0
- package/server/lib/context-builder.ts +210 -0
- package/server/lib/conversation.ts +587 -0
- package/server/lib/data-dir.ts +20 -0
- package/server/lib/display-bus.ts +21 -0
- package/server/lib/display-format.ts +23 -0
- package/server/lib/fuzzy-correct.ts +286 -0
- package/server/lib/hallucination-filter.ts +469 -0
- package/server/lib/local-day.ts +13 -0
- package/server/lib/model-router.ts +38 -0
- package/server/lib/openai-key.ts +155 -0
- package/server/lib/openai-whisper-budget.ts +170 -0
- package/server/lib/profile.ts +94 -0
- package/server/lib/python-bridge.ts +84 -0
- package/server/lib/response-cache.ts +138 -0
- package/server/lib/session-cache-writer.ts +266 -0
- package/server/lib/session-log.ts +162 -0
- package/server/lib/speaker-embeddings.ts +578 -0
- package/server/lib/telegram-notify.ts +85 -0
- package/server/lib/token-audit.ts +50 -0
- package/server/lib/transcribe-audio.ts +187 -0
- package/server/lib/utils.ts +5 -0
- package/server/lib/vad-silero.ts +179 -0
- package/server/lib/whisper-local.ts +697 -0
- package/server/routes/diag.ts +115 -0
- package/server/routes/display.ts +65 -0
- package/server/routes/health.ts +128 -0
- package/server/routes/openai-compat.ts +446 -0
- package/server/routes/openai-key.ts +121 -0
- package/server/routes/query.ts +121 -0
- package/server/routes/transcribe-stream.ts +1090 -0
- package/server/routes/transcribe.ts +55 -0
- package/shared/model-preference.ts +81 -0
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
// POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
|
|
2
|
+
// Uses local Whisper (50ms) with OpenAI API fallback.
|
|
3
|
+
// Streams speaker-labeled transcript chunks for live meeting capture.
|
|
4
|
+
|
|
5
|
+
import { Router } from 'express'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
|
|
8
|
+
import { writeFile } from 'node:fs/promises'
|
|
9
|
+
import { resolve } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { getVocabulary, getOwnerName } from '../lib/profile.js'
|
|
12
|
+
import { getOpenAIKey } from '../lib/openai-key.js'
|
|
13
|
+
|
|
14
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
15
|
+
import { emitDisplay } from '../lib/display-bus.js'
|
|
16
|
+
import { errMsg } from '../lib/utils.js'
|
|
17
|
+
import { transcribeLocal, isWhisperLocalAvailable, type WhisperWord } from '../lib/whisper-local.js'
|
|
18
|
+
import { enhanceAudio } from '../lib/audio-enhance.js'
|
|
19
|
+
import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
|
|
20
|
+
import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
|
|
21
|
+
import {
|
|
22
|
+
assertOpenAIWhisperBudget,
|
|
23
|
+
recordOpenAIWhisperUsage,
|
|
24
|
+
estimateAudioSeconds,
|
|
25
|
+
OpenAIWhisperBudgetExhaustedError,
|
|
26
|
+
} from '../lib/openai-whisper-budget.js'
|
|
27
|
+
import {
|
|
28
|
+
stripInlineHallucinations as sharedStripInlineHallucinations,
|
|
29
|
+
isFullHallucination as sharedIsFullHallucination,
|
|
30
|
+
clearSessionHallucinationState,
|
|
31
|
+
streamSilenceDropReason,
|
|
32
|
+
} from '../lib/hallucination-filter.js'
|
|
33
|
+
|
|
34
|
+
// Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
|
|
35
|
+
// brand-URL-only -> dropped ALWAYS (vocab-seeded, never real speech).
|
|
36
|
+
// any-URL-only / repeated-thank-you -> dropped only when isQuiet (rms<150), so a real
|
|
37
|
+
// softly-spoken utterance or a clearly-dictated third-party URL is never dropped.
|
|
38
|
+
// Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
|
|
39
|
+
const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
|
|
40
|
+
const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
|
|
41
|
+
|
|
42
|
+
// Audio persistence: save G2-mic chunks for speakers who need more training data
|
|
43
|
+
import { dataPath } from '../lib/data-dir.js'
|
|
44
|
+
const AUDIO_SAVE_DIR = dataPath('training-audio')
|
|
45
|
+
const MAX_SAVED_CHUNKS_PER_SPEAKER = 30 // ~5 min of audio per speaker, cleaned after training
|
|
46
|
+
|
|
47
|
+
// Unrecognized speaker audio: save Ext chunks for retroactive enrollment
|
|
48
|
+
const EXT_AUDIO_DIR = dataPath('ext-audio')
|
|
49
|
+
if (!existsSync(EXT_AUDIO_DIR)) mkdirSync(EXT_AUDIO_DIR, { recursive: true })
|
|
50
|
+
const EXT_AUDIO_TTL_MS = 72 * 60 * 60 * 1000 // 72-hour retention
|
|
51
|
+
const MAX_EXT_CHUNKS_PER_SESSION = 40 // cap per session to avoid runaway storage
|
|
52
|
+
const extAudioCounts = new Map<string, number>() // sessionId → chunk count
|
|
53
|
+
|
|
54
|
+
// ── Hallucination filter — delegated to shared lib (server/lib/hallucination-filter.ts) ──
|
|
55
|
+
// Local wrappers preserve existing call-site signatures; shared lib is the single source
|
|
56
|
+
// of truth, also used by /api/transcribe (one-shot message query path).
|
|
57
|
+
function stripInlineHallucinations(text: string, sessionId: string): string {
|
|
58
|
+
return sharedStripInlineHallucinations(text, sessionId)
|
|
59
|
+
}
|
|
60
|
+
function isServerHallucination(text: string): boolean {
|
|
61
|
+
return sharedIsFullHallucination(text)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Legacy constants kept empty — the block below used to define them but they now live
|
|
65
|
+
// in hallucination-filter.ts. Explicitly deleted (not undefined) so any stray references
|
|
66
|
+
// in older code fail loudly at compile time.
|
|
67
|
+
// (removed: KNOWN_HALLUCINATIONS, INLINE_HALLUCINATION_THRESHOLD, KNOWN_INLINE_HALLUCINATIONS,
|
|
68
|
+
// inlineNameFrequency, inlineBlocklist, INLINE_NAME_PATTERN, SOUND_DESCRIPTORS,
|
|
69
|
+
// SOUND_DESCRIPTOR_PATTERN, ASTERISK_CAPTION, WHOLE_CHUNK_ASTERISK, FILLER_WORDS)
|
|
70
|
+
const _unused_hallucination_constants_placeholder = 0 as const
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
// Session audio persistence: save all WAV chunks for batch re-transcription at save time
|
|
74
|
+
const SESSION_AUDIO_DIR = dataPath('session-audio')
|
|
75
|
+
if (!existsSync(SESSION_AUDIO_DIR)) mkdirSync(SESSION_AUDIO_DIR, { recursive: true })
|
|
76
|
+
const PENDING_BATCH_DIR = dataPath('pending-batch')
|
|
77
|
+
if (!existsSync(PENDING_BATCH_DIR)) mkdirSync(PENDING_BATCH_DIR, { recursive: true })
|
|
78
|
+
const MAX_SESSION_AUDIO_BYTES = 500 * 1024 * 1024 // 500MB cap per session (~2hr meeting ≈ 260MB)
|
|
79
|
+
const MAX_CANDIDATE_WAV_BASE64_CHARS = 8 * 1024 * 1024 // stay below server/index.ts 10mb JSON parser cap
|
|
80
|
+
const MAX_CANDIDATE_TEXT_CHARS = 8000
|
|
81
|
+
const MAX_CANDIDATE_WORDS = 1200
|
|
82
|
+
const sessionAudioBytes = new Map<string, number>() // track per-session byte count
|
|
83
|
+
const sessionAudioWrites = new Map<string, Set<Promise<void>>>()
|
|
84
|
+
|
|
85
|
+
// In-memory training audio counts — lazy-initialized from disk on first access per speaker
|
|
86
|
+
const trainingAudioCounts = new Map<string, number>()
|
|
87
|
+
function getTrainingCount(speakerDir: string): number {
|
|
88
|
+
let count = trainingAudioCounts.get(speakerDir)
|
|
89
|
+
if (count === undefined) {
|
|
90
|
+
try {
|
|
91
|
+
if (existsSync(speakerDir)) {
|
|
92
|
+
count = readdirSync(speakerDir).filter((f: string) => f.endsWith('.wav')).length
|
|
93
|
+
} else {
|
|
94
|
+
count = 0
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
count = 0
|
|
98
|
+
}
|
|
99
|
+
trainingAudioCounts.set(speakerDir, count)
|
|
100
|
+
}
|
|
101
|
+
return count
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sha256Hex(buffer: Buffer): string {
|
|
105
|
+
return createHash('sha256').update(buffer).digest('hex')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function candidateKey(record: Pick<ProviderCandidateRecord, 'provider' | 'chunkIndex' | 'audioSha256'>): string {
|
|
109
|
+
return `${record.chunkIndex}:${record.provider}:${record.audioSha256}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function trackSessionAudioWrite(sessionId: string, writeJob: Promise<void>): Promise<void> {
|
|
113
|
+
let writes = sessionAudioWrites.get(sessionId)
|
|
114
|
+
if (!writes) {
|
|
115
|
+
writes = new Set()
|
|
116
|
+
sessionAudioWrites.set(sessionId, writes)
|
|
117
|
+
}
|
|
118
|
+
writes.add(writeJob)
|
|
119
|
+
writeJob.finally(() => {
|
|
120
|
+
writes?.delete(writeJob)
|
|
121
|
+
if (writes && writes.size === 0) sessionAudioWrites.delete(sessionId)
|
|
122
|
+
}).catch(() => {})
|
|
123
|
+
return writeJob
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function drainSessionAudioWrites(sessionId: string): Promise<void> {
|
|
127
|
+
const writes = sessionAudioWrites.get(sessionId)
|
|
128
|
+
if (!writes || writes.size === 0) return
|
|
129
|
+
const settled = await Promise.allSettled([...writes])
|
|
130
|
+
const rejected = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
|
131
|
+
if (rejected) throw rejected.reason
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const transcribeStreamRouter = Router()
|
|
135
|
+
|
|
136
|
+
// Session accumulator: sessionId -> { chunks, startTime, title }
|
|
137
|
+
export interface TranscriptChunk {
|
|
138
|
+
text: string
|
|
139
|
+
speaker: string
|
|
140
|
+
elapsed: number // ms since session start
|
|
141
|
+
similarity: number // speaker identification confidence (0-1)
|
|
142
|
+
words?: WhisperWord[] // word-level timestamps from DTW alignment
|
|
143
|
+
asrProvider?: 'server-whisper' | 'iphone-whisperkit-beta'
|
|
144
|
+
backend?: string
|
|
145
|
+
model?: string
|
|
146
|
+
mode?: string
|
|
147
|
+
fallbackReason?: string
|
|
148
|
+
latencyMs?: number
|
|
149
|
+
audioSha256?: string
|
|
150
|
+
canonical?: boolean
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ProviderCandidateRecord {
|
|
154
|
+
provider: 'iphone-whisperkit-beta'
|
|
155
|
+
chunkIndex: number
|
|
156
|
+
elapsed: number
|
|
157
|
+
audioSha256: string
|
|
158
|
+
text: string
|
|
159
|
+
words?: WhisperWord[]
|
|
160
|
+
latencyMs?: number
|
|
161
|
+
model?: string
|
|
162
|
+
mode?: string
|
|
163
|
+
receivedAt: number
|
|
164
|
+
accepted?: boolean
|
|
165
|
+
fallbackReason?: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface TranscriptSession {
|
|
169
|
+
chunks: TranscriptChunk[]
|
|
170
|
+
startTime: number
|
|
171
|
+
title: string
|
|
172
|
+
providerCandidates?: Record<string, ProviderCandidateRecord>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const sessions = new Map<string, TranscriptSession>()
|
|
176
|
+
const CLOSED_SESSION_TTL_MS = 4 * 60 * 60 * 1000
|
|
177
|
+
const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
|
|
178
|
+
|
|
179
|
+
// Incremental chunk persistence — survive server restarts
|
|
180
|
+
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
181
|
+
if (!existsSync(CHUNK_PERSIST_DIR)) mkdirSync(CHUNK_PERSIST_DIR, { recursive: true })
|
|
182
|
+
|
|
183
|
+
function readClosedSessions(): Record<string, number> {
|
|
184
|
+
if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(readFileSync(CLOSED_SESSIONS_FILE, 'utf-8')) as unknown
|
|
187
|
+
if (!parsed || typeof parsed !== 'object') return {}
|
|
188
|
+
return Object.fromEntries(
|
|
189
|
+
Object.entries(parsed as Record<string, unknown>)
|
|
190
|
+
.filter(([id, ts]) => /^[A-Za-z0-9:_-]{3,96}$/.test(id) && typeof ts === 'number'),
|
|
191
|
+
) as Record<string, number>
|
|
192
|
+
} catch {
|
|
193
|
+
try {
|
|
194
|
+
renameSync(CLOSED_SESSIONS_FILE, `${CLOSED_SESSIONS_FILE}.corrupt.${Date.now()}`)
|
|
195
|
+
} catch {}
|
|
196
|
+
return {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function persistClosedSessions(): void {
|
|
201
|
+
const now = Date.now()
|
|
202
|
+
const merged = readClosedSessions()
|
|
203
|
+
for (const id of deletedSessions) merged[id] = now
|
|
204
|
+
for (const [id, closedAt] of Object.entries(merged)) {
|
|
205
|
+
if (now - closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
|
|
209
|
+
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf-8')
|
|
210
|
+
renameSync(tmp, CLOSED_SESSIONS_FILE)
|
|
211
|
+
} catch { /* best-effort tombstones */ }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function recoverClosedSessions(): void {
|
|
215
|
+
const now = Date.now()
|
|
216
|
+
const closed = readClosedSessions()
|
|
217
|
+
let dirty = false
|
|
218
|
+
for (const [id, closedAt] of Object.entries(closed)) {
|
|
219
|
+
if (now - closedAt <= CLOSED_SESSION_TTL_MS) {
|
|
220
|
+
deletedSessions.add(id)
|
|
221
|
+
} else {
|
|
222
|
+
delete closed[id]
|
|
223
|
+
dirty = true
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (dirty) {
|
|
227
|
+
try {
|
|
228
|
+
const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
|
|
229
|
+
writeFileSync(tmp, JSON.stringify(closed, null, 2), 'utf-8')
|
|
230
|
+
renameSync(tmp, CLOSED_SESSIONS_FILE)
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Persist a session's chunks to disk (called after each new chunk) */
|
|
236
|
+
function persistSession(sessionId: string): void {
|
|
237
|
+
try {
|
|
238
|
+
const session = sessions.get(sessionId)
|
|
239
|
+
if (!session) return
|
|
240
|
+
const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
|
|
241
|
+
const data = JSON.stringify({
|
|
242
|
+
sessionId,
|
|
243
|
+
startTime: session.startTime,
|
|
244
|
+
title: session.title,
|
|
245
|
+
chunks: session.chunks.filter(c => c && c.text),
|
|
246
|
+
providerCandidates: session.providerCandidates ?? {},
|
|
247
|
+
})
|
|
248
|
+
writeFileSync(filePath, data, 'utf-8')
|
|
249
|
+
} catch { /* non-critical — don't break transcription for persistence */ }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Recover sessions from disk on server restart */
|
|
253
|
+
function recoverSessions(): void {
|
|
254
|
+
try {
|
|
255
|
+
if (!existsSync(CHUNK_PERSIST_DIR)) return
|
|
256
|
+
const files = readdirSync(CHUNK_PERSIST_DIR) as string[]
|
|
257
|
+
const recoveredIds = new Set<string>()
|
|
258
|
+
for (const file of files) {
|
|
259
|
+
if (!file.endsWith('.json')) continue
|
|
260
|
+
try {
|
|
261
|
+
const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
|
|
262
|
+
if (data.sessionId && data.chunks && data.chunks.length > 0) {
|
|
263
|
+
// Only recover sessions less than 4 hours old
|
|
264
|
+
if (Date.now() - data.startTime < 4 * 60 * 60 * 1000) {
|
|
265
|
+
const session: TranscriptSession = {
|
|
266
|
+
chunks: data.chunks,
|
|
267
|
+
startTime: data.startTime,
|
|
268
|
+
title: data.title || '',
|
|
269
|
+
providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
|
|
270
|
+
? data.providerCandidates
|
|
271
|
+
: {},
|
|
272
|
+
}
|
|
273
|
+
// Retroactively strip inline hallucinations from recovered chunks.
|
|
274
|
+
// Two-pass: (1) scan all chunks to build frequency/blocklist,
|
|
275
|
+
// (2) strip from all chunks using the final blocklist.
|
|
276
|
+
// Single-pass would miss non-seeded names in early chunks.
|
|
277
|
+
for (const chunk of session.chunks) {
|
|
278
|
+
if (chunk?.text) stripInlineHallucinations(chunk.text, data.sessionId) // pass 1: build blocklist
|
|
279
|
+
}
|
|
280
|
+
let cleaned = 0
|
|
281
|
+
for (const chunk of session.chunks) {
|
|
282
|
+
if (chunk?.text) {
|
|
283
|
+
const stripped = stripInlineHallucinations(chunk.text, data.sessionId)
|
|
284
|
+
if (stripped !== chunk.text) {
|
|
285
|
+
chunk.text = stripped
|
|
286
|
+
cleaned++
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
sessions.set(data.sessionId, session)
|
|
291
|
+
recoveredIds.add(data.sessionId)
|
|
292
|
+
if (cleaned > 0) {
|
|
293
|
+
console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
|
|
294
|
+
persistSession(data.sessionId) // re-persist cleaned data to disk
|
|
295
|
+
}
|
|
296
|
+
console.log(`[session-recovery] Recovered ${data.chunks.length} chunks for ${data.sessionId}`)
|
|
297
|
+
} else {
|
|
298
|
+
// Stale — clean up
|
|
299
|
+
unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} catch { /* skip corrupt files */ }
|
|
303
|
+
}
|
|
304
|
+
// Remove orphaned session-audio dirs with no matching recovered session
|
|
305
|
+
try {
|
|
306
|
+
if (existsSync(SESSION_AUDIO_DIR)) {
|
|
307
|
+
for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
|
|
308
|
+
if (!recoveredIds.has(dir)) {
|
|
309
|
+
rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
|
|
310
|
+
console.log(`[session-recovery] Cleaned orphaned session-audio: ${dir}`)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch {}
|
|
315
|
+
} catch { /* non-critical */ }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Track recently-deleted session IDs so the /diag/client endpoint can return 410 Gone
|
|
319
|
+
// to zombie clients still heartbeating into non-existent sessions (spam mitigation).
|
|
320
|
+
// Declared BEFORE deleteSession to avoid TDZ hazard (deleteSession references these).
|
|
321
|
+
const deletedSessions = new Set<string>()
|
|
322
|
+
const DELETED_SESSION_CAP = 50 // keep last 50 deleted IDs, trim older on overflow
|
|
323
|
+
export function isSessionDeleted(sessionId: string): boolean {
|
|
324
|
+
return deletedSessions.has(sessionId)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Recover any sessions from prior server instance.
|
|
328
|
+
recoverClosedSessions()
|
|
329
|
+
recoverSessions()
|
|
330
|
+
|
|
331
|
+
// Auto-cleanup sessions older than 4 hours
|
|
332
|
+
setInterval(() => {
|
|
333
|
+
const cutoff = Date.now() - 4 * 60 * 60 * 1000
|
|
334
|
+
for (const [id, session] of sessions) {
|
|
335
|
+
if (session.startTime < cutoff) {
|
|
336
|
+
sessions.delete(id)
|
|
337
|
+
sessionAudioBytes.delete(id)
|
|
338
|
+
sessionAudioWrites.delete(id)
|
|
339
|
+
clearSessionHallucinationState(id)
|
|
340
|
+
// Clean up persisted file too
|
|
341
|
+
try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${id}.json`)) } catch {}
|
|
342
|
+
// Clean up session audio
|
|
343
|
+
try { rmSync(resolve(SESSION_AUDIO_DIR, id), { recursive: true, force: true }) } catch {}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Purge orphaned session-audio dirs (no matching active session)
|
|
347
|
+
try {
|
|
348
|
+
for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
|
|
349
|
+
if (!sessions.has(dir)) {
|
|
350
|
+
rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch {}
|
|
354
|
+
// Purge stale pending-batch dirs older than 2 hours after move (batch should complete in minutes).
|
|
355
|
+
// Age measured by _batch_pending.marker mtime (set by moveSessionAudioToPending), NOT the first
|
|
356
|
+
// chunk's mtime — chunk files keep their original write-time across the atomic rename, so for
|
|
357
|
+
// meetings > 1 hour, chunk mtimes would always look stale. Fallback: dir mtime for older marker-less dirs.
|
|
358
|
+
try {
|
|
359
|
+
for (const dir of readdirSync(PENDING_BATCH_DIR)) {
|
|
360
|
+
const dirPath = resolve(PENDING_BATCH_DIR, dir)
|
|
361
|
+
try {
|
|
362
|
+
const files = readdirSync(dirPath)
|
|
363
|
+
if (files.length === 0) { rmSync(dirPath, { recursive: true, force: true }); continue }
|
|
364
|
+
// Prefer the marker file mtime over first chunk mtime
|
|
365
|
+
const markerPath = resolve(dirPath, '_batch_pending.marker')
|
|
366
|
+
let ageSource: number
|
|
367
|
+
if (existsSync(markerPath)) {
|
|
368
|
+
ageSource = statSync(markerPath).mtimeMs
|
|
369
|
+
} else {
|
|
370
|
+
// Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
|
|
371
|
+
ageSource = statSync(dirPath).ctimeMs
|
|
372
|
+
}
|
|
373
|
+
if (Date.now() - ageSource > 2 * 60 * 60 * 1000) {
|
|
374
|
+
rmSync(dirPath, { recursive: true, force: true })
|
|
375
|
+
console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
|
|
376
|
+
}
|
|
377
|
+
} catch {}
|
|
378
|
+
}
|
|
379
|
+
} catch {}
|
|
380
|
+
|
|
381
|
+
// Purge ext-audio dirs older than 72 hours
|
|
382
|
+
try {
|
|
383
|
+
if (existsSync(EXT_AUDIO_DIR)) {
|
|
384
|
+
for (const dir of readdirSync(EXT_AUDIO_DIR)) {
|
|
385
|
+
const dirPath = resolve(EXT_AUDIO_DIR, dir)
|
|
386
|
+
try {
|
|
387
|
+
const files = readdirSync(dirPath)
|
|
388
|
+
if (files.length === 0) { rmSync(dirPath, { recursive: true, force: true }); continue }
|
|
389
|
+
const { mtimeMs } = statSync(resolve(dirPath, files[0]))
|
|
390
|
+
if (Date.now() - mtimeMs > EXT_AUDIO_TTL_MS) {
|
|
391
|
+
rmSync(dirPath, { recursive: true, force: true })
|
|
392
|
+
extAudioCounts.delete(dir)
|
|
393
|
+
console.log(`[ext-audio] Purged expired ext-audio: ${dir} (>72h)`)
|
|
394
|
+
}
|
|
395
|
+
} catch {}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} catch {}
|
|
399
|
+
persistClosedSessions()
|
|
400
|
+
}, 60_000)
|
|
401
|
+
|
|
402
|
+
/** Get or create a transcript session */
|
|
403
|
+
export function getSession(sessionId: string): TranscriptSession {
|
|
404
|
+
let session = sessions.get(sessionId)
|
|
405
|
+
if (!session) {
|
|
406
|
+
session = { chunks: [], startTime: Date.now(), title: '', providerCandidates: {} }
|
|
407
|
+
sessions.set(sessionId, session)
|
|
408
|
+
}
|
|
409
|
+
if (!session.providerCandidates) session.providerCandidates = {}
|
|
410
|
+
return session
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Get full accumulated transcript for a session (with speaker labels) */
|
|
414
|
+
export function getSessionTranscript(sessionId: string): string | null {
|
|
415
|
+
const session = sessions.get(sessionId)
|
|
416
|
+
if (!session) return null
|
|
417
|
+
return session.chunks
|
|
418
|
+
.map(c => c.speaker ? `[${c.speaker}]: ${c.text}` : c.text)
|
|
419
|
+
.join('\n')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Get structured chunks with timing + speaker confidence (for blended meeting pipeline) */
|
|
423
|
+
export function getSessionChunks(sessionId: string): TranscriptChunk[] | null {
|
|
424
|
+
const session = sessions.get(sessionId)
|
|
425
|
+
if (!session) return null
|
|
426
|
+
return session.chunks.filter(c => c && c.text)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Get session start time */
|
|
430
|
+
export function getSessionStartTime(sessionId: string): number | null {
|
|
431
|
+
return sessions.get(sessionId)?.startTime ?? null
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Move session audio to pending-batch before deletion (batch re-transcription needs it) */
|
|
435
|
+
export function moveSessionAudioToPending(sessionId: string): string | null {
|
|
436
|
+
const srcDir = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
437
|
+
if (!existsSync(srcDir)) return null
|
|
438
|
+
const destDir = resolve(PENDING_BATCH_DIR, sessionId)
|
|
439
|
+
try {
|
|
440
|
+
// Rename is atomic on same filesystem
|
|
441
|
+
renameSync(srcDir, destDir)
|
|
442
|
+
// Write marker file with move timestamp. The cleanup interval checks THIS file's
|
|
443
|
+
// mtime for TTL, not individual chunk files (whose mtimes date from original write).
|
|
444
|
+
// Without this marker, meetings > 1 hour have first chunks older than the 1-hour
|
|
445
|
+
// TTL, causing the interval to purge pending-batch before batch re-transcription
|
|
446
|
+
// can run. Observed 2026-04-13: 103-min meeting lost audio to this race condition.
|
|
447
|
+
try { writeFileSync(resolve(destDir, '_batch_pending.marker'), String(Date.now()), 'utf-8') } catch {}
|
|
448
|
+
return destDir
|
|
449
|
+
} catch (err: unknown) {
|
|
450
|
+
console.warn(`[session-audio] Failed to move to pending-batch: ${errMsg(err)}`)
|
|
451
|
+
return null
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function getSessionProviderCandidates(sessionId: string): Record<string, ProviderCandidateRecord> {
|
|
456
|
+
return sessions.get(sessionId)?.providerCandidates ?? {}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Delete session after save */
|
|
460
|
+
export function deleteSession(sessionId: string): void {
|
|
461
|
+
sessions.delete(sessionId)
|
|
462
|
+
sessionAudioBytes.delete(sessionId)
|
|
463
|
+
// Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
|
|
464
|
+
clearSessionHallucinationState(sessionId)
|
|
465
|
+
// Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
|
|
466
|
+
deletedSessions.add(sessionId)
|
|
467
|
+
if (deletedSessions.size > DELETED_SESSION_CAP) {
|
|
468
|
+
// Trim oldest entries to prevent unbounded growth
|
|
469
|
+
const arr = Array.from(deletedSessions)
|
|
470
|
+
for (const id of arr.slice(0, arr.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
|
|
471
|
+
}
|
|
472
|
+
persistClosedSessions()
|
|
473
|
+
// Clean up persisted file
|
|
474
|
+
try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
|
|
475
|
+
// Clean up session audio if it wasn't moved to pending-batch
|
|
476
|
+
const audioDir = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
477
|
+
try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function isIosAsrCandidateEnabled(): boolean {
|
|
481
|
+
return process.env.COS_IOS_ASR_CANDIDATES === '1'
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function makeHttpError(status: number, message: string, reason?: string): Error & { status?: number; reason?: string } {
|
|
485
|
+
const err = new Error(message) as Error & { status?: number; reason?: string }
|
|
486
|
+
err.status = status
|
|
487
|
+
err.reason = reason
|
|
488
|
+
return err
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function validateSessionId(sessionId: string): void {
|
|
492
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
493
|
+
throw makeHttpError(400, 'invalid sessionId', 'invalid_session_id')
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function validateChunkIndex(chunkIndex: number): void {
|
|
498
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex > 9999) {
|
|
499
|
+
throw makeHttpError(400, 'invalid chunkIndex', 'invalid_chunk_index')
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function normalizeCandidateText(value: unknown): string {
|
|
504
|
+
const text = String(value ?? '')
|
|
505
|
+
if (text.length > MAX_CANDIDATE_TEXT_CHARS) {
|
|
506
|
+
throw makeHttpError(413, 'candidate text too large', 'candidate_text_too_large')
|
|
507
|
+
}
|
|
508
|
+
return text
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function normalizeCandidateWords(value: unknown): WhisperWord[] | undefined {
|
|
512
|
+
if (!Array.isArray(value)) return undefined
|
|
513
|
+
const normalized: WhisperWord[] = []
|
|
514
|
+
for (const item of value.slice(0, MAX_CANDIDATE_WORDS)) {
|
|
515
|
+
if (!item || typeof item !== 'object') continue
|
|
516
|
+
const raw = item as Record<string, unknown>
|
|
517
|
+
const word = typeof raw.word === 'string' ? raw.word.trim().slice(0, 80) : ''
|
|
518
|
+
const start = typeof raw.start === 'number' && Number.isFinite(raw.start) ? raw.start : undefined
|
|
519
|
+
const end = typeof raw.end === 'number' && Number.isFinite(raw.end) ? raw.end : undefined
|
|
520
|
+
const probability = typeof raw.probability === 'number' && Number.isFinite(raw.probability)
|
|
521
|
+
? raw.probability
|
|
522
|
+
: 0
|
|
523
|
+
if (!word || start == null || end == null) continue
|
|
524
|
+
normalized.push({ word, start, end, probability })
|
|
525
|
+
}
|
|
526
|
+
return normalized.length > 0 ? normalized : undefined
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Promise<Buffer> {
|
|
530
|
+
const buffers: Buffer[] = []
|
|
531
|
+
for await (const chunk of req) {
|
|
532
|
+
buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
533
|
+
}
|
|
534
|
+
return Buffer.concat(buffers)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number, audioBuffer: Buffer): Promise<void> {
|
|
538
|
+
const sessionDir = resolve(SESSION_AUDIO_DIR, sessionId)
|
|
539
|
+
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true })
|
|
540
|
+
const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
|
|
541
|
+
const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
|
|
542
|
+
const currentBytes = sessionAudioBytes.get(sessionId) ?? 0
|
|
543
|
+
const nextBytes = currentBytes - existingSize + audioBuffer.length
|
|
544
|
+
if (nextBytes > MAX_SESSION_AUDIO_BYTES && !existsSync(chunkPath)) {
|
|
545
|
+
if (chunkIndex % 50 === 0) console.warn(`[session-audio] Session ${sessionId} hit 500MB cap — skipping WAV saves`)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
const writeJob = writeFile(chunkPath, audioBuffer)
|
|
549
|
+
await trackSessionAudioWrite(sessionId, writeJob)
|
|
550
|
+
sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function recentWhisperContext(sessionId: string, session: TranscriptSession): string {
|
|
554
|
+
const recentText = session.chunks
|
|
555
|
+
.filter(c => {
|
|
556
|
+
if (!c?.text) return false
|
|
557
|
+
const words = c.text.toLowerCase().replace(/[.!?,;:'"()\-\n]/g, '').split(/\s+/).filter((w: string) => w)
|
|
558
|
+
if (words.length < 2) return false
|
|
559
|
+
const unique = new Set(words)
|
|
560
|
+
if (unique.size === 1) return false
|
|
561
|
+
const wordCounts = new Map<string, number>()
|
|
562
|
+
for (const w of words) wordCounts.set(w, (wordCounts.get(w) ?? 0) + 1)
|
|
563
|
+
const maxCount = Math.max(...wordCounts.values())
|
|
564
|
+
if (maxCount / words.length > 0.7) return false
|
|
565
|
+
return true
|
|
566
|
+
})
|
|
567
|
+
.slice(-5)
|
|
568
|
+
.map(c => c.text)
|
|
569
|
+
.join(' ')
|
|
570
|
+
const cleanedContext = stripInlineHallucinations(recentText, sessionId)
|
|
571
|
+
return cleanedContext.length > 250 ? cleanedContext.slice(-250) : cleanedContext
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function isCrossChunkRepeat(session: TranscriptSession, text: string): boolean {
|
|
575
|
+
const norm = text.toLowerCase().replace(/[.!?,;:'"()\-\n]/g, '').trim()
|
|
576
|
+
if (norm.length === 0 || norm.split(/\s+/).length > 6) return false
|
|
577
|
+
const recent = session.chunks.slice(-3).filter(c => c?.text)
|
|
578
|
+
const repeats = recent.filter(c =>
|
|
579
|
+
c.text.toLowerCase().replace(/[.!?,;:'"()\-\n]/g, '').trim() === norm
|
|
580
|
+
).length
|
|
581
|
+
return repeats >= 2
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession, rawText: string, isQuiet = false): { text: string; fallbackReason?: string } {
|
|
585
|
+
let trimmedText = rawText?.trim() || ''
|
|
586
|
+
if (trimmedText) {
|
|
587
|
+
try { trimmedText = stripInlineHallucinations(trimmedText, sessionId) } catch { /* keep raw text */ }
|
|
588
|
+
}
|
|
589
|
+
// Silence-hallucination drops. brand-URL-only fires regardless of isQuiet (brand URLs
|
|
590
|
+
// are vocab-seeded and never a real standalone utterance); generic-URL-only and
|
|
591
|
+
// repeated-thank-you fire only when isQuiet so real soft speech / dictated URLs survive.
|
|
592
|
+
// Dropped chunks return '' and never enter session.chunks, so they don't pollute
|
|
593
|
+
// context priming or cross-chunk-repeat history. See streamSilenceDropReason contract.
|
|
594
|
+
if (trimmedText) {
|
|
595
|
+
const dropReason = streamSilenceDropReason(trimmedText, isQuiet)
|
|
596
|
+
const flagAllows = dropReason === 'thankyou_silence' ? THANKYOU_FILTER : STRIP_BRAND_URLS
|
|
597
|
+
if (dropReason && flagAllows) {
|
|
598
|
+
console.log(`[hallucination] Dropped (${dropReason}, q=${isQuiet ? 1 : 0}): "${trimmedText.slice(0, 60)}"`)
|
|
599
|
+
return { text: '', fallbackReason: dropReason }
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (trimmedText && isServerHallucination(trimmedText)) {
|
|
603
|
+
return { text: '', fallbackReason: 'hallucination' }
|
|
604
|
+
}
|
|
605
|
+
if (trimmedText && isCrossChunkRepeat(session, trimmedText)) {
|
|
606
|
+
return { text: '', fallbackReason: 'cross_chunk_repeat' }
|
|
607
|
+
}
|
|
608
|
+
return { text: trimmedText }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function storeProviderCandidate(
|
|
612
|
+
session: TranscriptSession,
|
|
613
|
+
record: ProviderCandidateRecord,
|
|
614
|
+
): string {
|
|
615
|
+
session.providerCandidates ??= {}
|
|
616
|
+
const key = candidateKey(record)
|
|
617
|
+
session.providerCandidates[key] = record
|
|
618
|
+
return key
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function canonicalChunkResponse(
|
|
622
|
+
existing: TranscriptChunk,
|
|
623
|
+
sessionId: string,
|
|
624
|
+
chunkIndex: number,
|
|
625
|
+
): { text: string; speaker: string; chunkIndex: number; elapsed: number; sessionId: string; backend?: string; asrProvider?: string; fallbackReason?: string } {
|
|
626
|
+
return {
|
|
627
|
+
text: existing.text,
|
|
628
|
+
speaker: existing.speaker,
|
|
629
|
+
chunkIndex,
|
|
630
|
+
elapsed: existing.elapsed,
|
|
631
|
+
sessionId,
|
|
632
|
+
backend: existing.backend,
|
|
633
|
+
asrProvider: existing.asrProvider,
|
|
634
|
+
fallbackReason: existing.fallbackReason,
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
|
|
639
|
+
if (isWhisperLocalAvailable()) {
|
|
640
|
+
try {
|
|
641
|
+
const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
|
|
642
|
+
return { text: result.text, words: result.words, backend: `local-${result.backend}` }
|
|
643
|
+
} catch (err: unknown) {
|
|
644
|
+
console.warn(`[transcribe-stream] Local Whisper failed, falling back to cloud: ${errMsg(err)}`)
|
|
645
|
+
const text = await transcribeViaCloud(whisperAudio)
|
|
646
|
+
return { text, words: undefined, backend: 'cloud' }
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const text = await transcribeViaCloud(whisperAudio)
|
|
650
|
+
return { text, words: undefined, backend: 'cloud' }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
|
|
654
|
+
const expectedSpeakers = undefined
|
|
655
|
+
const audioDurationSec = Math.max(0, (audioBuffer.length - 44)) / 32000
|
|
656
|
+
if (!isEmbeddingAvailable() || audioDurationSec < 2.0) return { speaker: clientSpeaker, similarity: 0 }
|
|
657
|
+
|
|
658
|
+
const tEmb = performance.now()
|
|
659
|
+
const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers)
|
|
660
|
+
console.log(`[perf] identifySpeaker: ${(performance.now() - tEmb).toFixed(1)}ms`)
|
|
661
|
+
if (!embeddingResult) return { speaker: clientSpeaker, similarity: 0 }
|
|
662
|
+
|
|
663
|
+
let speaker = embeddingResult.speaker
|
|
664
|
+
if (speaker !== clientSpeaker) {
|
|
665
|
+
console.log(`[speaker] Embedding: ${speaker} vs Amplitude: ${clientSpeaker} (sim: ${embeddingResult.similarity.toFixed(2)})`)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (embeddingResult.similarity >= 0.72 && speaker !== 'Ext') {
|
|
669
|
+
const enrollResult = autoEnroll(speaker, audioBuffer, embeddingResult.similarity, sessionId)
|
|
670
|
+
if (enrollResult.enrolled) {
|
|
671
|
+
console.log(`[speaker] Auto-enrolled ${speaker} from G2 mic (sim: ${embeddingResult.similarity.toFixed(3)})`)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (speaker !== 'Ext' && embeddingResult.similarity > 0.50) {
|
|
676
|
+
const embCount = getEmbeddingCount(speaker)
|
|
677
|
+
if (embCount < 20) {
|
|
678
|
+
try {
|
|
679
|
+
const speakerDir = resolve(AUDIO_SAVE_DIR, speaker.replace(/\s+/g, '_'))
|
|
680
|
+
if (!existsSync(speakerDir)) mkdirSync(speakerDir, { recursive: true })
|
|
681
|
+
const existing = getTrainingCount(speakerDir)
|
|
682
|
+
if (existing < MAX_SAVED_CHUNKS_PER_SPEAKER) {
|
|
683
|
+
const filename = `${sessionId}_chunk${chunkIndex}_sim${embeddingResult.similarity.toFixed(2)}.wav`
|
|
684
|
+
const savePath = resolve(speakerDir, filename)
|
|
685
|
+
writeFile(savePath, audioBuffer).catch(err =>
|
|
686
|
+
console.warn(`[training-audio] Async save failed for ${speaker}: ${err.message}`)
|
|
687
|
+
)
|
|
688
|
+
trainingAudioCounts.set(speakerDir, existing + 1)
|
|
689
|
+
console.log(`[training-audio] Saved ${speaker} chunk (sim=${embeddingResult.similarity.toFixed(2)}, ${audioBuffer.length}b, total=${existing + 1})`)
|
|
690
|
+
}
|
|
691
|
+
} catch (audioSaveErr: unknown) {
|
|
692
|
+
console.warn(`[training-audio] Save failed for ${speaker}: ${errMsg(audioSaveErr)}`)
|
|
693
|
+
}
|
|
694
|
+
} else if (chunkIndex % 20 === 0) {
|
|
695
|
+
console.log(`[training-audio] ${speaker} at ${embCount} embeddings (>= 15), skipping save`)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (speaker === 'Ext' && audioBuffer.length >= 16000) {
|
|
700
|
+
const extSessionDir = resolve(EXT_AUDIO_DIR, sessionId)
|
|
701
|
+
if (!existsSync(extSessionDir)) mkdirSync(extSessionDir, { recursive: true })
|
|
702
|
+
const extCount = extAudioCounts.get(sessionId) ?? 0
|
|
703
|
+
if (extCount < MAX_EXT_CHUNKS_PER_SESSION) {
|
|
704
|
+
const filename = `ext_chunk${chunkIndex}_${Date.now()}.wav`
|
|
705
|
+
writeFile(resolve(extSessionDir, filename), audioBuffer).catch(err =>
|
|
706
|
+
console.warn(`[ext-audio] Save failed: ${err.message}`)
|
|
707
|
+
)
|
|
708
|
+
extAudioCounts.set(sessionId, extCount + 1)
|
|
709
|
+
if (extCount === 0 || (extCount + 1) % 10 === 0) {
|
|
710
|
+
console.log(`[ext-audio] Saved Ext chunk for session ${sessionId} (total=${extCount + 1})`)
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return { speaker, similarity: embeddingResult.similarity }
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
interface StreamCandidateInput {
|
|
719
|
+
provider: 'iphone-whisperkit-beta'
|
|
720
|
+
text: string
|
|
721
|
+
words?: WhisperWord[]
|
|
722
|
+
latencyMs?: number
|
|
723
|
+
model?: string
|
|
724
|
+
mode?: string
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function processStreamChunk(opts: {
|
|
728
|
+
sessionId: string
|
|
729
|
+
chunkIndex: number
|
|
730
|
+
clientSpeaker: string
|
|
731
|
+
audioBuffer: Buffer
|
|
732
|
+
candidate?: StreamCandidateInput
|
|
733
|
+
clientElapsed?: number
|
|
734
|
+
}): Promise<{ text: string; speaker: string; chunkIndex: number; elapsed: number; sessionId: string; backend?: string; asrProvider?: string; fallbackReason?: string }> {
|
|
735
|
+
const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
|
|
736
|
+
const tReq = performance.now()
|
|
737
|
+
validateSessionId(sessionId)
|
|
738
|
+
validateChunkIndex(chunkIndex)
|
|
739
|
+
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
740
|
+
if (audioBuffer.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
|
|
741
|
+
|
|
742
|
+
const audioSha256 = sha256Hex(audioBuffer)
|
|
743
|
+
const session = getSession(sessionId)
|
|
744
|
+
const alreadyCanonical = session.chunks[chunkIndex]
|
|
745
|
+
|
|
746
|
+
let candidateRecordKey: string | undefined
|
|
747
|
+
if (candidate) {
|
|
748
|
+
const record: ProviderCandidateRecord = {
|
|
749
|
+
provider: candidate.provider,
|
|
750
|
+
chunkIndex,
|
|
751
|
+
elapsed: Number.isFinite(opts.clientElapsed) ? Number(opts.clientElapsed) : 0,
|
|
752
|
+
audioSha256,
|
|
753
|
+
text: candidate.text ?? '',
|
|
754
|
+
words: candidate.words,
|
|
755
|
+
latencyMs: candidate.latencyMs,
|
|
756
|
+
model: candidate.model,
|
|
757
|
+
mode: candidate.mode,
|
|
758
|
+
receivedAt: Date.now(),
|
|
759
|
+
}
|
|
760
|
+
candidateRecordKey = storeProviderCandidate(session, record)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Do not let late duplicate/replayed candidates replace canonical raw audio.
|
|
764
|
+
// Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
|
|
765
|
+
if (alreadyCanonical?.canonical) {
|
|
766
|
+
if (candidate && candidateRecordKey) {
|
|
767
|
+
session.providerCandidates![candidateRecordKey].accepted =
|
|
768
|
+
alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
|
|
769
|
+
session.providerCandidates![candidateRecordKey].fallbackReason =
|
|
770
|
+
session.providerCandidates![candidateRecordKey].accepted ? undefined : 'canonical_exists'
|
|
771
|
+
persistSession(sessionId)
|
|
772
|
+
}
|
|
773
|
+
return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
|
|
777
|
+
|
|
778
|
+
if (candidate) {
|
|
779
|
+
persistSession(sessionId)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const pcmData = audioBuffer.subarray(44)
|
|
783
|
+
let sumSq = 0
|
|
784
|
+
const nSamples = Math.floor(pcmData.length / 2)
|
|
785
|
+
for (let i = 0; i < nSamples; i++) {
|
|
786
|
+
const s = pcmData.readInt16LE(i * 2)
|
|
787
|
+
sumSq += s * s
|
|
788
|
+
}
|
|
789
|
+
const rms = Math.sqrt(sumSq / Math.max(1, nSamples))
|
|
790
|
+
const isQuiet = rms < 150
|
|
791
|
+
const whisperAudio = isQuiet ? audioBuffer : await enhanceAudio(audioBuffer)
|
|
792
|
+
const whisperContext = recentWhisperContext(sessionId, session)
|
|
793
|
+
console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
|
|
794
|
+
|
|
795
|
+
const speakerPromise = Promise.resolve(identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker))
|
|
796
|
+
|
|
797
|
+
let rawText = ''
|
|
798
|
+
let words: WhisperWord[] | undefined
|
|
799
|
+
let backend = 'candidate'
|
|
800
|
+
let asrProvider: 'server-whisper' | 'iphone-whisperkit-beta' = 'server-whisper'
|
|
801
|
+
let model: string | undefined
|
|
802
|
+
let mode: string | undefined
|
|
803
|
+
let latencyMs: number | undefined
|
|
804
|
+
let fallbackReason: string | undefined
|
|
805
|
+
|
|
806
|
+
if (candidate) {
|
|
807
|
+
rawText = candidate.text ?? ''
|
|
808
|
+
words = candidate.words
|
|
809
|
+
asrProvider = 'iphone-whisperkit-beta'
|
|
810
|
+
model = candidate.model
|
|
811
|
+
mode = candidate.mode
|
|
812
|
+
latencyMs = candidate.latencyMs
|
|
813
|
+
} else {
|
|
814
|
+
const t0 = performance.now()
|
|
815
|
+
const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
|
|
816
|
+
console.log(`[perf] whisper total: ${(performance.now() - t0).toFixed(1)}ms`)
|
|
817
|
+
rawText = result.text
|
|
818
|
+
words = result.words
|
|
819
|
+
backend = result.backend
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
|
|
823
|
+
if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
|
|
824
|
+
fallbackReason = sanitized.fallbackReason || 'empty_candidate'
|
|
825
|
+
const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
|
|
826
|
+
rawText = result.text
|
|
827
|
+
words = result.words
|
|
828
|
+
backend = result.backend
|
|
829
|
+
asrProvider = 'server-whisper'
|
|
830
|
+
sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const { speaker, similarity } = await speakerPromise
|
|
834
|
+
const elapsed = Date.now() - session.startTime
|
|
835
|
+
const trimmedText = sanitized.text
|
|
836
|
+
|
|
837
|
+
if (!trimmedText) {
|
|
838
|
+
console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): "${rawText.slice(0, 60)}"`)
|
|
839
|
+
if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
|
|
840
|
+
session.providerCandidates[candidateRecordKey].accepted = false
|
|
841
|
+
session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
|
|
842
|
+
persistSession(sessionId)
|
|
843
|
+
}
|
|
844
|
+
return { text: '', speaker: clientSpeaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason: sanitized.fallbackReason || fallbackReason }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const chunk: TranscriptChunk = {
|
|
848
|
+
text: trimmedText,
|
|
849
|
+
speaker,
|
|
850
|
+
elapsed,
|
|
851
|
+
similarity,
|
|
852
|
+
words,
|
|
853
|
+
asrProvider,
|
|
854
|
+
backend,
|
|
855
|
+
model,
|
|
856
|
+
mode,
|
|
857
|
+
fallbackReason,
|
|
858
|
+
latencyMs,
|
|
859
|
+
audioSha256,
|
|
860
|
+
canonical: true,
|
|
861
|
+
}
|
|
862
|
+
const finalExisting = session.chunks[chunkIndex]
|
|
863
|
+
if (finalExisting?.canonical) {
|
|
864
|
+
if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
|
|
865
|
+
session.providerCandidates[candidateRecordKey].accepted =
|
|
866
|
+
finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
|
|
867
|
+
session.providerCandidates[candidateRecordKey].fallbackReason =
|
|
868
|
+
session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
|
|
869
|
+
persistSession(sessionId)
|
|
870
|
+
}
|
|
871
|
+
return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
|
|
872
|
+
}
|
|
873
|
+
session.chunks[chunkIndex] = chunk
|
|
874
|
+
if (candidate && candidateRecordKey) {
|
|
875
|
+
if (session.providerCandidates?.[candidateRecordKey]) {
|
|
876
|
+
session.providerCandidates[candidateRecordKey].accepted = asrProvider === 'iphone-whisperkit-beta'
|
|
877
|
+
session.providerCandidates[candidateRecordKey].fallbackReason = fallbackReason
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const tPersist = performance.now()
|
|
881
|
+
persistSession(sessionId)
|
|
882
|
+
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
883
|
+
|
|
884
|
+
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
885
|
+
|
|
886
|
+
console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
|
|
887
|
+
return { text: trimmedText, speaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason }
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
|
|
891
|
+
if (err instanceof OpenAIWhisperBudgetExhaustedError) {
|
|
892
|
+
console.error(`[transcribe-stream] ${err.message}`)
|
|
893
|
+
return res.status(503).json({
|
|
894
|
+
error: err.message,
|
|
895
|
+
reason: 'openai_whisper_budget_exhausted',
|
|
896
|
+
spent_today_usd: err.spentTodayUsd,
|
|
897
|
+
cap_usd: err.capUsd,
|
|
898
|
+
})
|
|
899
|
+
}
|
|
900
|
+
const status = typeof (err as any)?.status === 'number' ? (err as any).status : 500
|
|
901
|
+
return res.status(status).json({ error: errMsg(err), reason: (err as any)?.reason })
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
|
|
905
|
+
try {
|
|
906
|
+
const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
|
|
907
|
+
const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
|
|
908
|
+
const clientSpeaker = (req.query.speaker as string) || 'Unknown'
|
|
909
|
+
const audioBuffer = await readRawBody(req)
|
|
910
|
+
res.json(await processStreamChunk({ sessionId, chunkIndex, clientSpeaker, audioBuffer }))
|
|
911
|
+
} catch (err: unknown) {
|
|
912
|
+
sendStreamError(res, err)
|
|
913
|
+
}
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (req, res) => {
|
|
917
|
+
try {
|
|
918
|
+
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
919
|
+
const body = req.body ?? {}
|
|
920
|
+
const sessionId = String(body.sessionId ?? '')
|
|
921
|
+
validateSessionId(sessionId)
|
|
922
|
+
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
923
|
+
const session = getSession(sessionId)
|
|
924
|
+
const startTime = typeof body.startTime === 'number' && Number.isFinite(body.startTime)
|
|
925
|
+
? Number(body.startTime)
|
|
926
|
+
: undefined
|
|
927
|
+
if (startTime && session.chunks.filter(Boolean).length === 0) session.startTime = startTime
|
|
928
|
+
if (typeof body.title === 'string') session.title = body.title.slice(0, 160)
|
|
929
|
+
persistSession(sessionId)
|
|
930
|
+
res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
|
|
931
|
+
} catch (err: unknown) {
|
|
932
|
+
sendStreamError(res, err)
|
|
933
|
+
}
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chunks', async (req, res) => {
|
|
937
|
+
try {
|
|
938
|
+
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
939
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
940
|
+
validateSessionId(sessionId)
|
|
941
|
+
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
942
|
+
const body = req.body ?? {}
|
|
943
|
+
const chunkIndex = Number(body.chunkIndex)
|
|
944
|
+
validateChunkIndex(chunkIndex)
|
|
945
|
+
if (chunkIndex > 0 && !sessions.has(sessionId)) {
|
|
946
|
+
throw makeHttpError(404, 'offline session not started', 'session_not_found')
|
|
947
|
+
}
|
|
948
|
+
if (body.provider !== 'iphone-whisperkit-beta') throw makeHttpError(400, 'invalid provider', 'invalid_provider')
|
|
949
|
+
const wavBase64 = String(body.wavBase64 ?? '')
|
|
950
|
+
if (!wavBase64 || wavBase64.length > MAX_CANDIDATE_WAV_BASE64_CHARS) throw makeHttpError(400, 'invalid audio payload', 'invalid_audio')
|
|
951
|
+
const audioBuffer = Buffer.from(wavBase64, 'base64')
|
|
952
|
+
const audioSha256 = sha256Hex(audioBuffer)
|
|
953
|
+
if (String(body.audioSha256 ?? '') !== audioSha256) throw makeHttpError(400, 'audio hash mismatch', 'audio_hash_mismatch')
|
|
954
|
+
const candidate = body.candidate ?? {}
|
|
955
|
+
const result = await processStreamChunk({
|
|
956
|
+
sessionId,
|
|
957
|
+
chunkIndex,
|
|
958
|
+
clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
|
|
959
|
+
audioBuffer,
|
|
960
|
+
clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
|
|
961
|
+
candidate: {
|
|
962
|
+
provider: 'iphone-whisperkit-beta',
|
|
963
|
+
text: normalizeCandidateText(candidate.text),
|
|
964
|
+
words: normalizeCandidateWords(candidate.words),
|
|
965
|
+
latencyMs: typeof candidate.latencyMs === 'number' ? candidate.latencyMs : undefined,
|
|
966
|
+
model: typeof candidate.model === 'string' ? candidate.model : undefined,
|
|
967
|
+
mode: typeof candidate.mode === 'string' ? candidate.mode : undefined,
|
|
968
|
+
},
|
|
969
|
+
})
|
|
970
|
+
res.json({ ...result, offlineReplay: true })
|
|
971
|
+
} catch (err: unknown) {
|
|
972
|
+
sendStreamError(res, err)
|
|
973
|
+
}
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/finalize', async (req, res) => {
|
|
977
|
+
try {
|
|
978
|
+
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
979
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
980
|
+
validateSessionId(sessionId)
|
|
981
|
+
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
982
|
+
const chunks = getSessionChunks(sessionId)
|
|
983
|
+
if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
|
|
984
|
+
await drainSessionAudioWrites(sessionId)
|
|
985
|
+
const transcript = getSessionTranscript(sessionId) ?? ''
|
|
986
|
+
res.json({
|
|
987
|
+
sessionId,
|
|
988
|
+
chunks: chunks.length,
|
|
989
|
+
transcriptChars: transcript.length,
|
|
990
|
+
readyToSave: true,
|
|
991
|
+
})
|
|
992
|
+
} catch (err: unknown) {
|
|
993
|
+
sendStreamError(res, err)
|
|
994
|
+
}
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) => {
|
|
998
|
+
try {
|
|
999
|
+
if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
|
|
1000
|
+
const body = req.body ?? {}
|
|
1001
|
+
const sessionId = String(body.sessionId ?? '')
|
|
1002
|
+
const chunkIndex = Number(body.chunkIndex)
|
|
1003
|
+
validateSessionId(sessionId)
|
|
1004
|
+
validateChunkIndex(chunkIndex)
|
|
1005
|
+
if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
|
|
1006
|
+
if (chunkIndex > 0 && !sessions.has(sessionId)) {
|
|
1007
|
+
throw makeHttpError(404, 'session not found', 'session_not_found')
|
|
1008
|
+
}
|
|
1009
|
+
if (body.provider !== 'iphone-whisperkit-beta') throw makeHttpError(400, 'invalid provider', 'invalid_provider')
|
|
1010
|
+
const wavBase64 = String(body.wavBase64 ?? '')
|
|
1011
|
+
if (!wavBase64 || wavBase64.length > MAX_CANDIDATE_WAV_BASE64_CHARS) throw makeHttpError(400, 'invalid audio payload', 'invalid_audio')
|
|
1012
|
+
const audioBuffer = Buffer.from(wavBase64, 'base64')
|
|
1013
|
+
const audioSha256 = sha256Hex(audioBuffer)
|
|
1014
|
+
if (String(body.audioSha256 ?? '') !== audioSha256) throw makeHttpError(400, 'audio hash mismatch', 'audio_hash_mismatch')
|
|
1015
|
+
const candidate = body.candidate ?? {}
|
|
1016
|
+
const result = await processStreamChunk({
|
|
1017
|
+
sessionId,
|
|
1018
|
+
chunkIndex,
|
|
1019
|
+
clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
|
|
1020
|
+
audioBuffer,
|
|
1021
|
+
clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
|
|
1022
|
+
candidate: {
|
|
1023
|
+
provider: 'iphone-whisperkit-beta',
|
|
1024
|
+
text: normalizeCandidateText(candidate.text),
|
|
1025
|
+
words: normalizeCandidateWords(candidate.words),
|
|
1026
|
+
latencyMs: typeof candidate.latencyMs === 'number' ? candidate.latencyMs : undefined,
|
|
1027
|
+
model: typeof candidate.model === 'string' ? candidate.model : undefined,
|
|
1028
|
+
mode: typeof candidate.mode === 'string' ? candidate.mode : undefined,
|
|
1029
|
+
},
|
|
1030
|
+
})
|
|
1031
|
+
res.json(result)
|
|
1032
|
+
} catch (err: unknown) {
|
|
1033
|
+
sendStreamError(res, err)
|
|
1034
|
+
}
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
/** Fallback: transcribe via OpenAI Whisper API.
|
|
1038
|
+
* Budget-gated: throws OpenAIWhisperBudgetExhaustedError if today's $5 cap is spent.
|
|
1039
|
+
* A hung whisper-server + long meeting is the exact scenario this guards against —
|
|
1040
|
+
* chunks stay empty on budget-exceeded instead of silently billing per chunk. */
|
|
1041
|
+
async function transcribeViaCloud(audioBuffer: Buffer): Promise<string> {
|
|
1042
|
+
assertOpenAIWhisperBudget()
|
|
1043
|
+
|
|
1044
|
+
const key = getOpenAIKey()
|
|
1045
|
+
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
1046
|
+
|
|
1047
|
+
const isWav = audioBuffer.length >= 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'
|
|
1048
|
+
const filename = isWav ? 'recording.wav' : 'recording.webm'
|
|
1049
|
+
const mimeType = isWav ? 'audio/wav' : 'audio/webm'
|
|
1050
|
+
|
|
1051
|
+
const boundary = '----COS' + Date.now()
|
|
1052
|
+
const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`
|
|
1053
|
+
const modelPart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1`
|
|
1054
|
+
const languagePart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nen`
|
|
1055
|
+
const vocab = getVocabulary()
|
|
1056
|
+
const cloudPrompt = vocab.length > 0
|
|
1057
|
+
? [getOwnerName(), ...vocab].join(', ')
|
|
1058
|
+
: `${getOwnerName()}, COS Glasses, Even G2`
|
|
1059
|
+
const promptPart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="prompt"\r\n\r\n${cloudPrompt}`
|
|
1060
|
+
const footer = `\r\n--${boundary}--\r\n`
|
|
1061
|
+
|
|
1062
|
+
const body = Buffer.concat([
|
|
1063
|
+
Buffer.from(header),
|
|
1064
|
+
audioBuffer,
|
|
1065
|
+
Buffer.from(modelPart),
|
|
1066
|
+
Buffer.from(languagePart),
|
|
1067
|
+
Buffer.from(promptPart),
|
|
1068
|
+
Buffer.from(footer),
|
|
1069
|
+
])
|
|
1070
|
+
|
|
1071
|
+
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
1072
|
+
method: 'POST',
|
|
1073
|
+
headers: {
|
|
1074
|
+
'Authorization': `Bearer ${key}`,
|
|
1075
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
1076
|
+
},
|
|
1077
|
+
body,
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
if (!response.ok) {
|
|
1081
|
+
const errText = await response.text()
|
|
1082
|
+
// Don't bill a failed call — API returned non-2xx, no transcription produced.
|
|
1083
|
+
throw new Error(`Whisper API: ${errText.slice(0, 200)}`)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const result = await response.json() as { text: string }
|
|
1087
|
+
// Success — count the audio we sent against today's budget.
|
|
1088
|
+
recordOpenAIWhisperUsage(audioSeconds)
|
|
1089
|
+
return result.text?.trim() || ''
|
|
1090
|
+
}
|