@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.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/server/index.ts +14 -0
- package/server/lib/bookmarks.ts +96 -0
- package/server/lib/handoff-store.ts +404 -0
- package/server/lib/meeting-batch-transcribe.ts +1 -1
- 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/lib/whisper-local.ts +43 -24
- 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,709 @@
|
|
|
1
|
+
// POST /api/tts/stream — proxy OpenAI gpt-4o-mini-tts streaming bytes to the
|
|
2
|
+
// companion app so Voice Mode can read responses aloud over the user's paired
|
|
3
|
+
// Bluetooth/AirPods.
|
|
4
|
+
//
|
|
5
|
+
// Why proxy: the OPENAI_API_KEY must never reach the client. We also enforce a
|
|
6
|
+
// per-day budget cap (mirrors the Whisper budget) so a runaway voice loop can't
|
|
7
|
+
// silently rack up cost.
|
|
8
|
+
//
|
|
9
|
+
// Why streaming: gpt-4o-mini-tts can stream audio bytes as they're generated
|
|
10
|
+
// (Chunked Transfer-Encoding). We pipe OpenAI's response body straight to our
|
|
11
|
+
// HTTP response — first byte from OpenAI = first byte to the companion. That
|
|
12
|
+
// gets first-audio under ~1s for typical responses.
|
|
13
|
+
|
|
14
|
+
import { Router } from 'express'
|
|
15
|
+
import { errMsg } from '../lib/utils.js'
|
|
16
|
+
import { getOpenAIKey } from '../lib/openai-key.js'
|
|
17
|
+
import {
|
|
18
|
+
assertOpenAITtsBudget,
|
|
19
|
+
recordOpenAITtsUsage,
|
|
20
|
+
OpenAITtsBudgetExhaustedError,
|
|
21
|
+
} from '../lib/openai-tts-budget.js'
|
|
22
|
+
import {
|
|
23
|
+
hashKey,
|
|
24
|
+
getCached,
|
|
25
|
+
startEntry,
|
|
26
|
+
appendBytes,
|
|
27
|
+
completeEntry,
|
|
28
|
+
abortEntry,
|
|
29
|
+
createSession,
|
|
30
|
+
peekSession,
|
|
31
|
+
reapExpiredSessions,
|
|
32
|
+
waitForInFlight,
|
|
33
|
+
getCacheStats,
|
|
34
|
+
} from '../lib/tts-cache.js'
|
|
35
|
+
|
|
36
|
+
export const ttsRouter = Router()
|
|
37
|
+
|
|
38
|
+
// Sweep expired sessions every 30s. Cheap O(N) scan over a tiny map (sessions
|
|
39
|
+
// live <= 60s and arrive at human-tap rate). Single interval lives for the
|
|
40
|
+
// process lifetime — no teardown needed.
|
|
41
|
+
setInterval(reapExpiredSessions, 30_000).unref()
|
|
42
|
+
|
|
43
|
+
// Hard text length cap — OpenAI gpt-4o-mini-tts accepts up to 4096 input chars.
|
|
44
|
+
// Anything longer would be rejected; we trim defensively at a sentence boundary
|
|
45
|
+
// near the cap so the audio doesn't end mid-word.
|
|
46
|
+
const MAX_TTS_CHARS = 4000
|
|
47
|
+
|
|
48
|
+
// OpenAI voice IDs supported by gpt-4o-mini-tts. Default is alloy (warm,
|
|
49
|
+
// neutral, gender-neutral). Voice can be overridden per-request and the
|
|
50
|
+
// server-side default is configurable via COS_VOICE_DEFAULT.
|
|
51
|
+
const SUPPORTED_VOICES = new Set([
|
|
52
|
+
'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer', 'ash', 'sage', 'coral',
|
|
53
|
+
])
|
|
54
|
+
const DEFAULT_VOICE = (() => {
|
|
55
|
+
const env = process.env.COS_VOICE_DEFAULT
|
|
56
|
+
return env && SUPPORTED_VOICES.has(env) ? env : 'echo'
|
|
57
|
+
})()
|
|
58
|
+
|
|
59
|
+
const DEFAULT_INSTRUCTIONS = process.env.COS_VOICE_INSTRUCTIONS || ''
|
|
60
|
+
|
|
61
|
+
// Audio output formats. mp3 is the safest cross-platform default (HTML5 audio
|
|
62
|
+
// + iOS WKWebView both decode it natively). opus is smaller but MSE support is
|
|
63
|
+
// patchier on iOS Safari.
|
|
64
|
+
const SUPPORTED_FORMATS = new Set(['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'])
|
|
65
|
+
const FORMAT_MIME: Record<string, string> = {
|
|
66
|
+
mp3: 'audio/mpeg',
|
|
67
|
+
opus: 'audio/ogg',
|
|
68
|
+
aac: 'audio/aac',
|
|
69
|
+
flac: 'audio/flac',
|
|
70
|
+
wav: 'audio/wav',
|
|
71
|
+
pcm: 'audio/pcm',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Trim text to MAX_TTS_CHARS at a sentence boundary if possible. */
|
|
75
|
+
function trimToCap(text: string): string {
|
|
76
|
+
if (text.length <= MAX_TTS_CHARS) return text
|
|
77
|
+
const slice = text.slice(0, MAX_TTS_CHARS)
|
|
78
|
+
// Walk back to the last sentence terminator (.!?) to avoid mid-word cuts.
|
|
79
|
+
const lastTerm = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('! '), slice.lastIndexOf('? '))
|
|
80
|
+
if (lastTerm > MAX_TTS_CHARS * 0.6) return slice.slice(0, lastTerm + 1)
|
|
81
|
+
// Fall back to the last word boundary.
|
|
82
|
+
const lastSpace = slice.lastIndexOf(' ')
|
|
83
|
+
return lastSpace > 0 ? slice.slice(0, lastSpace) : slice
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Defensive markdown strip — client should already have done this, but a
|
|
87
|
+
* caller (or future archive playback) might pass raw markdown. Cheap regex set,
|
|
88
|
+
* matches the client-side stripMarkdown() at src/lib/display-pages.ts. */
|
|
89
|
+
function stripMarkdownLight(text: string): string {
|
|
90
|
+
return text
|
|
91
|
+
.replace(/#{1,6}\s+/g, '')
|
|
92
|
+
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
93
|
+
.replace(/\*(.+?)\*/g, '$1')
|
|
94
|
+
.replace(/`(.+?)`/g, '$1')
|
|
95
|
+
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
|
|
96
|
+
.replace(/^[-*+]\s/gm, '- ')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── v5.9.6 fast-prefix splitter ───────────────────────────────────────────
|
|
100
|
+
//
|
|
101
|
+
// The "fast first-audio" path (POST /api/tts/prepare with fast: true) wants
|
|
102
|
+
// to start playing audio in ~1-2s instead of the 8-15s a full-message OpenAI
|
|
103
|
+
// render takes for long replies. We do that by splitting the input into a
|
|
104
|
+
// short prefix the client can play immediately, and a tail that gets
|
|
105
|
+
// generated in parallel and chained on prefix `ended`.
|
|
106
|
+
//
|
|
107
|
+
// Heuristic-only — no NLP dependency. Markdown is already stripped above.
|
|
108
|
+
// Boundary detection uses the same .!? + whitespace rule as trimToCap so the
|
|
109
|
+
// two stay consistent. Bounded lengths protect against pathological inputs:
|
|
110
|
+
// - MIN_PREFIX_CHARS: short greetings ("Hi.") get padded with the next
|
|
111
|
+
// sentence so the prefix is long enough to mask tail-render latency.
|
|
112
|
+
// - MAX_PREFIX_CHARS: a single long sentence ("So basically I think we…
|
|
113
|
+
// spanning 600 chars") gets cut at a word boundary instead of running on.
|
|
114
|
+
const MIN_PREFIX_CHARS = 60
|
|
115
|
+
const MAX_PREFIX_CHARS = 250
|
|
116
|
+
|
|
117
|
+
/** Split `text` into a fast-playable prefix + a tail.
|
|
118
|
+
*
|
|
119
|
+
* Contract:
|
|
120
|
+
* - Returns `{ prefix, tail }` with `prefix` non-empty and `tail` either ''
|
|
121
|
+
* (the message fits in one chunk and the route should fall back to v5.9.5
|
|
122
|
+
* single-URL behavior) or the remainder.
|
|
123
|
+
* - Prefix targets the first ~2 sentences but expands if either is short
|
|
124
|
+
* (to clear MIN_PREFIX_CHARS) and contracts if a single sentence exceeds
|
|
125
|
+
* MAX_PREFIX_CHARS (cut at the last word boundary inside the cap).
|
|
126
|
+
* - Caller is responsible for trimToCap'ping the input first. */
|
|
127
|
+
export function splitForFastPrefix(text: string): { prefix: string; tail: string } {
|
|
128
|
+
const trimmed = text.trim()
|
|
129
|
+
if (trimmed.length === 0) return { prefix: '', tail: '' }
|
|
130
|
+
// Short enough to play as a single chunk — no benefit from splitting.
|
|
131
|
+
if (trimmed.length <= MIN_PREFIX_CHARS) return { prefix: trimmed, tail: '' }
|
|
132
|
+
|
|
133
|
+
// Walk sentence terminators forward, accumulating sentences until we cover
|
|
134
|
+
// at least MIN_PREFIX_CHARS. Up to 2 sentences if both are reasonably sized,
|
|
135
|
+
// more if the first ones are tiny. Indices point to the boundary AFTER the
|
|
136
|
+
// terminator + whitespace (the start of the next sentence).
|
|
137
|
+
const sentenceBoundaries: number[] = []
|
|
138
|
+
const re = /[.!?]\s+/g
|
|
139
|
+
let m: RegExpExecArray | null
|
|
140
|
+
while ((m = re.exec(trimmed)) !== null) {
|
|
141
|
+
sentenceBoundaries.push(m.index + m[0].length)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (sentenceBoundaries.length === 0) {
|
|
145
|
+
// No sentence terminators (one giant run-on). Fall back to a word-boundary
|
|
146
|
+
// cut at MAX_PREFIX_CHARS. If the whole thing fits in MAX, it's a single chunk.
|
|
147
|
+
if (trimmed.length <= MAX_PREFIX_CHARS) return { prefix: trimmed, tail: '' }
|
|
148
|
+
const slice = trimmed.slice(0, MAX_PREFIX_CHARS)
|
|
149
|
+
const lastSpace = slice.lastIndexOf(' ')
|
|
150
|
+
const cut = lastSpace > MIN_PREFIX_CHARS ? lastSpace : MAX_PREFIX_CHARS
|
|
151
|
+
return {
|
|
152
|
+
prefix: trimmed.slice(0, cut).trim(),
|
|
153
|
+
tail: trimmed.slice(cut).trim(),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Pick the smallest cut that satisfies (length >= MIN_PREFIX_CHARS) AND
|
|
158
|
+
// covers >= 2 sentences when possible. Stop early once a candidate also
|
|
159
|
+
// exceeds MAX_PREFIX_CHARS — the previous candidate is the best fit.
|
|
160
|
+
let chosenCut = sentenceBoundaries[sentenceBoundaries.length - 1]
|
|
161
|
+
for (let i = 0; i < sentenceBoundaries.length; i++) {
|
|
162
|
+
const cut = sentenceBoundaries[i]
|
|
163
|
+
const sentencesCovered = i + 1
|
|
164
|
+
const longEnough = cut >= MIN_PREFIX_CHARS
|
|
165
|
+
const tooLong = cut > MAX_PREFIX_CHARS
|
|
166
|
+
const hasTwo = sentencesCovered >= 2
|
|
167
|
+
if (tooLong) {
|
|
168
|
+
// Previous boundary (if any) was the best fit; if this is the first
|
|
169
|
+
// boundary AND it already overshoots MAX, fall back to a word-boundary
|
|
170
|
+
// cut inside the first sentence so the prefix doesn't blow past the cap.
|
|
171
|
+
if (i === 0) {
|
|
172
|
+
const slice = trimmed.slice(0, MAX_PREFIX_CHARS)
|
|
173
|
+
const lastSpace = slice.lastIndexOf(' ')
|
|
174
|
+
const cutAt = lastSpace > MIN_PREFIX_CHARS ? lastSpace : MAX_PREFIX_CHARS
|
|
175
|
+
chosenCut = cutAt
|
|
176
|
+
} else {
|
|
177
|
+
chosenCut = sentenceBoundaries[i - 1]
|
|
178
|
+
}
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
if (longEnough && hasTwo) {
|
|
182
|
+
chosenCut = cut
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
chosenCut = cut
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const prefix = trimmed.slice(0, chosenCut).trim()
|
|
189
|
+
const tail = trimmed.slice(chosenCut).trim()
|
|
190
|
+
if (tail.length === 0) return { prefix: trimmed, tail: '' }
|
|
191
|
+
return { prefix, tail }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Drain an OpenAI TTS response into the in-memory + disk cache for the given
|
|
195
|
+
* hash. Used by both the play-route cache-miss path and the prepare-route
|
|
196
|
+
* pre-warm path. Returns true on success, false if anything aborted/failed —
|
|
197
|
+
* the cache entry is rolled back via abortEntry on failure so the next request
|
|
198
|
+
* for the same hash can try again from a clean slate.
|
|
199
|
+
*
|
|
200
|
+
* Does NOT write to any HTTP response; callers serve out of the cache after
|
|
201
|
+
* this resolves. Budget billing fires on first byte (same rule as today). */
|
|
202
|
+
async function generateIntoCache(
|
|
203
|
+
hash: string,
|
|
204
|
+
text: string,
|
|
205
|
+
voice: string,
|
|
206
|
+
format: string,
|
|
207
|
+
signal?: AbortSignal,
|
|
208
|
+
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
209
|
+
// Cheap pre-check — if it's already cached, skip everything.
|
|
210
|
+
if (getCached(hash)) return { ok: true }
|
|
211
|
+
|
|
212
|
+
let key: string
|
|
213
|
+
try {
|
|
214
|
+
key = getOpenAIKey()
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return { ok: false, status: 503, message: errMsg(err) }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Try to reserve the slot. Null means another writer beat us to it; wait
|
|
220
|
+
// for them rather than racing OpenAI. This is the dedup mechanism that
|
|
221
|
+
// makes parallel /prepare pre-warm + concurrent /play GETs safe.
|
|
222
|
+
const slot = startEntry(hash, voice, format)
|
|
223
|
+
if (!slot) {
|
|
224
|
+
const served = await waitForInFlight(hash, 30_000)
|
|
225
|
+
if (served) return { ok: true }
|
|
226
|
+
return { ok: false, status: 502, message: 'in-flight peer failed or timed out' }
|
|
227
|
+
}
|
|
228
|
+
let succeeded = false
|
|
229
|
+
|
|
230
|
+
let openaiRes: Response
|
|
231
|
+
try {
|
|
232
|
+
openaiRes = await fetch('https://api.openai.com/v1/audio/speech', {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: {
|
|
235
|
+
'Authorization': `Bearer ${key}`,
|
|
236
|
+
'Content-Type': 'application/json',
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
model: 'gpt-4o-mini-tts',
|
|
240
|
+
voice,
|
|
241
|
+
input: text,
|
|
242
|
+
response_format: format,
|
|
243
|
+
...(DEFAULT_INSTRUCTIONS ? { instructions: DEFAULT_INSTRUCTIONS } : {}),
|
|
244
|
+
}),
|
|
245
|
+
signal,
|
|
246
|
+
})
|
|
247
|
+
} catch (err) {
|
|
248
|
+
abortEntry(hash)
|
|
249
|
+
return { ok: false, status: 502, message: `OpenAI TTS fetch failed: ${errMsg(err)}` }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!openaiRes.ok || !openaiRes.body) {
|
|
253
|
+
abortEntry(hash)
|
|
254
|
+
const errText = await openaiRes.text().catch(() => '')
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
status: openaiRes.status || 502,
|
|
258
|
+
message: `OpenAI TTS ${openaiRes.status}: ${errText.slice(0, 300)}`,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const reader = openaiRes.body.getReader()
|
|
263
|
+
let firstByteSeen = false
|
|
264
|
+
try {
|
|
265
|
+
while (true) {
|
|
266
|
+
const { done, value } = await reader.read()
|
|
267
|
+
if (done) break
|
|
268
|
+
if (value && value.length > 0) {
|
|
269
|
+
const buf = Buffer.from(value)
|
|
270
|
+
if (!firstByteSeen) {
|
|
271
|
+
firstByteSeen = true
|
|
272
|
+
recordOpenAITtsUsage(text.length)
|
|
273
|
+
}
|
|
274
|
+
appendBytes(hash, buf)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
completeEntry(hash)
|
|
278
|
+
succeeded = true
|
|
279
|
+
return { ok: true }
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (!succeeded) abortEntry(hash)
|
|
282
|
+
if ((err as { name?: string })?.name === 'AbortError') {
|
|
283
|
+
return { ok: false, status: 499, message: 'client closed request' }
|
|
284
|
+
}
|
|
285
|
+
return { ok: false, status: 502, message: `OpenAI TTS drain failed: ${errMsg(err)}` }
|
|
286
|
+
} finally {
|
|
287
|
+
try { reader.releaseLock() } catch { /* already released */ }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
ttsRouter.post('/tts/stream', async (req, res) => {
|
|
292
|
+
try {
|
|
293
|
+
const { text, voice, format, instructions } = req.body ?? {}
|
|
294
|
+
|
|
295
|
+
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
296
|
+
return res.status(400).json({ error: 'text is required (non-empty string)' })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const requestedVoice = typeof voice === 'string' && SUPPORTED_VOICES.has(voice)
|
|
300
|
+
? voice : DEFAULT_VOICE
|
|
301
|
+
const requestedFormat = typeof format === 'string' && SUPPORTED_FORMATS.has(format)
|
|
302
|
+
? format : 'mp3'
|
|
303
|
+
const requestedInstructions = typeof instructions === 'string' && instructions.trim().length > 0
|
|
304
|
+
? instructions : DEFAULT_INSTRUCTIONS
|
|
305
|
+
|
|
306
|
+
// Budget gate — throw before the OpenAI call so we don't bill an aborted request.
|
|
307
|
+
try {
|
|
308
|
+
assertOpenAITtsBudget()
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (err instanceof OpenAITtsBudgetExhaustedError) {
|
|
311
|
+
return res.status(429).json({
|
|
312
|
+
error: err.message,
|
|
313
|
+
spentTodayUsd: err.spentTodayUsd,
|
|
314
|
+
capUsd: err.capUsd,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
throw err
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Resolve the OpenAI key — surfaces a clean 503 if no key is reachable.
|
|
321
|
+
let key: string
|
|
322
|
+
try {
|
|
323
|
+
key = getOpenAIKey()
|
|
324
|
+
} catch (err) {
|
|
325
|
+
return res.status(503).json({ error: errMsg(err) })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const cleaned = stripMarkdownLight(text).trim()
|
|
329
|
+
const capped = trimToCap(cleaned)
|
|
330
|
+
const charCount = capped.length
|
|
331
|
+
|
|
332
|
+
// Abort the upstream OpenAI request if the client disconnects mid-stream
|
|
333
|
+
// (e.g. user toggled Voice Mode off, or started a new query).
|
|
334
|
+
const upstreamController = new AbortController()
|
|
335
|
+
res.once('close', () => {
|
|
336
|
+
if (!res.writableEnded) upstreamController.abort()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const openaiRes = await fetch('https://api.openai.com/v1/audio/speech', {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: {
|
|
342
|
+
'Authorization': `Bearer ${key}`,
|
|
343
|
+
'Content-Type': 'application/json',
|
|
344
|
+
},
|
|
345
|
+
body: JSON.stringify({
|
|
346
|
+
model: 'gpt-4o-mini-tts',
|
|
347
|
+
voice: requestedVoice,
|
|
348
|
+
input: capped,
|
|
349
|
+
response_format: requestedFormat,
|
|
350
|
+
...(requestedInstructions ? { instructions: requestedInstructions } : {}),
|
|
351
|
+
}),
|
|
352
|
+
signal: upstreamController.signal,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
if (!openaiRes.ok || !openaiRes.body) {
|
|
356
|
+
const errText = await openaiRes.text().catch(() => '')
|
|
357
|
+
return res.status(openaiRes.status || 502).json({
|
|
358
|
+
error: `OpenAI TTS ${openaiRes.status}: ${errText.slice(0, 300)}`,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Set headers for the audio stream — flush immediately so the browser can
|
|
363
|
+
// start consuming bytes as soon as they arrive.
|
|
364
|
+
res.writeHead(200, {
|
|
365
|
+
'Content-Type': FORMAT_MIME[requestedFormat] ?? 'audio/mpeg',
|
|
366
|
+
'Cache-Control': 'no-cache',
|
|
367
|
+
'Transfer-Encoding': 'chunked',
|
|
368
|
+
'X-Accel-Buffering': 'no',
|
|
369
|
+
'Access-Control-Allow-Origin': '*',
|
|
370
|
+
})
|
|
371
|
+
res.flushHeaders()
|
|
372
|
+
|
|
373
|
+
// Pipe the upstream Web ReadableStream to the Express response. We track
|
|
374
|
+
// first-byte success so the budget ledger only ticks on a real (billable)
|
|
375
|
+
// response — aborts before any bytes don't count.
|
|
376
|
+
const reader = openaiRes.body.getReader()
|
|
377
|
+
let firstByteSeen = false
|
|
378
|
+
try {
|
|
379
|
+
while (true) {
|
|
380
|
+
const { done, value } = await reader.read()
|
|
381
|
+
if (done) break
|
|
382
|
+
if (value && value.length > 0) {
|
|
383
|
+
if (!firstByteSeen) {
|
|
384
|
+
firstByteSeen = true
|
|
385
|
+
recordOpenAITtsUsage(charCount)
|
|
386
|
+
}
|
|
387
|
+
if (!res.write(Buffer.from(value))) {
|
|
388
|
+
// Backpressure — wait for drain before pulling more bytes.
|
|
389
|
+
await new Promise<void>((resolve) => res.once('drain', () => resolve()))
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
res.end()
|
|
394
|
+
} catch (err) {
|
|
395
|
+
// AbortError = client closed the stream; not an error worth logging loudly.
|
|
396
|
+
if ((err as { name?: string })?.name !== 'AbortError') {
|
|
397
|
+
console.error('[tts] Stream pipe error:', errMsg(err))
|
|
398
|
+
}
|
|
399
|
+
if (!res.writableEnded) res.end()
|
|
400
|
+
} finally {
|
|
401
|
+
try { reader.releaseLock() } catch { /* already released */ }
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (!res.headersSent) {
|
|
405
|
+
res.status(500).json({ error: errMsg(err) })
|
|
406
|
+
} else if (!res.writableEnded) {
|
|
407
|
+
res.end()
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// POST /api/tts/prepare — v5.9.2 progressive-playback path.
|
|
413
|
+
//
|
|
414
|
+
// Why this exists alongside /tts/stream: iOS WKWebView cannot progressively
|
|
415
|
+
// decode an audio/mpeg stream that we feed via MediaSource Extensions, AND it
|
|
416
|
+
// won't play() a Blob until the entire blob is built. The only path that gets
|
|
417
|
+
// fast first-audio on iOS is setting audio.src to a URL the browser can GET
|
|
418
|
+
// directly, so iOS does its own native progressive MP3 decode.
|
|
419
|
+
//
|
|
420
|
+
// Flow:
|
|
421
|
+
// 1. Client POSTs {text, voice, format} here. We strip+trim+budget-check,
|
|
422
|
+
// hash the (text, voice, format) tuple, and return a session URL.
|
|
423
|
+
// 2. Client sets audio.src = `${apiBase}${sessionUrl}` and calls .play().
|
|
424
|
+
// 3. The browser GETs /api/tts/play/:session, which consumes the session
|
|
425
|
+
// and either serves cached bytes (instant) or kicks off OpenAI fresh.
|
|
426
|
+
//
|
|
427
|
+
// The two-step pattern is required because authentication on the play route
|
|
428
|
+
// would force XHR (no Range support, no progressive decoding). The session
|
|
429
|
+
// UUID IS the auth — short-lived (60s) and one-shot.
|
|
430
|
+
ttsRouter.post('/tts/prepare', async (req, res) => {
|
|
431
|
+
try {
|
|
432
|
+
const { text, voice, format, instructions, fast } = req.body ?? {}
|
|
433
|
+
|
|
434
|
+
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
435
|
+
return res.status(400).json({ error: 'text is required (non-empty string)' })
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const requestedVoice = typeof voice === 'string' && SUPPORTED_VOICES.has(voice)
|
|
439
|
+
? voice : DEFAULT_VOICE
|
|
440
|
+
const requestedFormat = typeof format === 'string' && SUPPORTED_FORMATS.has(format)
|
|
441
|
+
? format : 'mp3'
|
|
442
|
+
const requestedInstructions = typeof instructions === 'string' && instructions.trim().length > 0
|
|
443
|
+
? instructions : DEFAULT_INSTRUCTIONS
|
|
444
|
+
const fastMode = fast === true
|
|
445
|
+
|
|
446
|
+
// Budget gate — fail fast on prepare so we don't even hand out a session
|
|
447
|
+
// that would 429 on play. Cache hits intentionally still go through the
|
|
448
|
+
// full /play path (which short-circuits before billing), so we don't
|
|
449
|
+
// double-check budget here for hits — prepare is cheap regardless.
|
|
450
|
+
try {
|
|
451
|
+
assertOpenAITtsBudget()
|
|
452
|
+
} catch (err) {
|
|
453
|
+
if (err instanceof OpenAITtsBudgetExhaustedError) {
|
|
454
|
+
return res.status(429).json({
|
|
455
|
+
error: err.message,
|
|
456
|
+
spentTodayUsd: err.spentTodayUsd,
|
|
457
|
+
capUsd: err.capUsd,
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
throw err
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// requestedInstructions is intentionally NOT part of the session entry or
|
|
464
|
+
// the cache key today — no client surface passes it, and the server-side
|
|
465
|
+
// DEFAULT_INSTRUCTIONS is read at OpenAI-call time. If we ever surface
|
|
466
|
+
// per-message instructions, both must change in lockstep.
|
|
467
|
+
void requestedInstructions
|
|
468
|
+
|
|
469
|
+
const cleaned = stripMarkdownLight(text).trim()
|
|
470
|
+
const capped = trimToCap(cleaned)
|
|
471
|
+
|
|
472
|
+
// v5.9.5 path (or fast=true with a short message that doesn't split):
|
|
473
|
+
// single session, behavior unchanged from v5.9.5. The play route will
|
|
474
|
+
// either hit the cache or call OpenAI on demand.
|
|
475
|
+
if (!fastMode) {
|
|
476
|
+
const hash = hashKey(capped, requestedVoice, requestedFormat)
|
|
477
|
+
const uuid = createSession({ hash, text: capped, voice: requestedVoice, format: requestedFormat })
|
|
478
|
+
return res.json({ url: `/api/tts/play/${uuid}` })
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// v5.9.6 fast path: split into prefix + tail, mint 1-2 sessions, and
|
|
482
|
+
// pre-warm OpenAI for both in parallel. The very next GET on either
|
|
483
|
+
// session piggybacks on the in-flight pre-warm via waitForInFlight
|
|
484
|
+
// (no double-bill), so first-audio drops from ~10s to ~1.5s on long
|
|
485
|
+
// replies. Falls back to single-URL behavior automatically when the
|
|
486
|
+
// splitter decides the message is too short to benefit from chunking.
|
|
487
|
+
const { prefix, tail } = splitForFastPrefix(capped)
|
|
488
|
+
|
|
489
|
+
const prefixHash = hashKey(prefix, requestedVoice, requestedFormat)
|
|
490
|
+
const prefixUuid = createSession({
|
|
491
|
+
hash: prefixHash,
|
|
492
|
+
text: prefix,
|
|
493
|
+
voice: requestedVoice,
|
|
494
|
+
format: requestedFormat,
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// Pre-warm OpenAI for the prefix. fire-and-forget; generateIntoCache
|
|
498
|
+
// dedups internally if another caller (parallel /prepare or a racing
|
|
499
|
+
// /play GET) is already on this hash, so we can call it unconditionally.
|
|
500
|
+
void generateIntoCache(prefixHash, prefix, requestedVoice, requestedFormat)
|
|
501
|
+
.then((r) => {
|
|
502
|
+
if (!r.ok && r.status !== 499) {
|
|
503
|
+
console.warn('[tts/prepare] prefix pre-warm failed:', r.status, r.message)
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
if (tail.length === 0) {
|
|
508
|
+
// Short message — single chunk, no tailUrl. Client falls back to the
|
|
509
|
+
// v5.9.5 single-URL playback path automatically.
|
|
510
|
+
return res.json({ url: `/api/tts/play/${prefixUuid}` })
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const tailHash = hashKey(tail, requestedVoice, requestedFormat)
|
|
514
|
+
const tailUuid = createSession({
|
|
515
|
+
hash: tailHash,
|
|
516
|
+
text: tail,
|
|
517
|
+
voice: requestedVoice,
|
|
518
|
+
format: requestedFormat,
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
void generateIntoCache(tailHash, tail, requestedVoice, requestedFormat)
|
|
522
|
+
.then((r) => {
|
|
523
|
+
if (!r.ok && r.status !== 499) {
|
|
524
|
+
console.warn('[tts/prepare] tail pre-warm failed:', r.status, r.message)
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
res.json({
|
|
529
|
+
url: `/api/tts/play/${prefixUuid}`,
|
|
530
|
+
tailUrl: `/api/tts/play/${tailUuid}`,
|
|
531
|
+
})
|
|
532
|
+
} catch (err) {
|
|
533
|
+
res.status(500).json({ error: errMsg(err) })
|
|
534
|
+
}
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
/** Serve a fully-buffered audio body, honoring HTTP Range if the client asked
|
|
538
|
+
* for one. Used by both the cache-hit fast path and the cache-miss path
|
|
539
|
+
* (after we've drained OpenAI fully into memory).
|
|
540
|
+
*
|
|
541
|
+
* Why this matters (v5.9.4): iOS WKWebView's HTML5 audio element issues
|
|
542
|
+
* Range requests (Range: bytes=N-) every few seconds during playback to
|
|
543
|
+
* refill its decoder buffer. Without 206/Content-Range support, iOS returns
|
|
544
|
+
* to the same audio.src URL, gets a 200 with the full body again (or worse,
|
|
545
|
+
* a 404 if the session was one-shot), and stalls. The result on the user
|
|
546
|
+
* side was "first ~10s plays, then silence" — exactly one decoder-buffer's
|
|
547
|
+
* worth of audio. We always advertise Accept-Ranges so iOS knows it's safe
|
|
548
|
+
* to issue Range requests, and we slice the cached buffer to satisfy them. */
|
|
549
|
+
function serveCachedBody(
|
|
550
|
+
req: import('express').Request,
|
|
551
|
+
res: import('express').Response,
|
|
552
|
+
bytes: Buffer,
|
|
553
|
+
totalSize: number,
|
|
554
|
+
mime: string,
|
|
555
|
+
): void {
|
|
556
|
+
if (!Buffer.isBuffer(bytes)) {
|
|
557
|
+
res.status(502).json({ error: 'TTS cache body unavailable' })
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
const rangeHeader = req.headers.range
|
|
561
|
+
if (typeof rangeHeader === 'string' && rangeHeader.startsWith('bytes=')) {
|
|
562
|
+
const m = rangeHeader.match(/^bytes=(\d+)-(\d*)$/)
|
|
563
|
+
if (!m) {
|
|
564
|
+
res.writeHead(416, {
|
|
565
|
+
'Content-Range': `bytes */${totalSize}`,
|
|
566
|
+
'Access-Control-Allow-Origin': '*',
|
|
567
|
+
})
|
|
568
|
+
res.end()
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
const start = Number(m[1])
|
|
572
|
+
const end = m[2] ? Math.min(Number(m[2]), totalSize - 1) : totalSize - 1
|
|
573
|
+
if (start >= totalSize || start > end) {
|
|
574
|
+
res.writeHead(416, {
|
|
575
|
+
'Content-Range': `bytes */${totalSize}`,
|
|
576
|
+
'Access-Control-Allow-Origin': '*',
|
|
577
|
+
})
|
|
578
|
+
res.end()
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
const slice = bytes.subarray(start, end + 1)
|
|
582
|
+
res.writeHead(206, {
|
|
583
|
+
'Content-Type': mime,
|
|
584
|
+
'Content-Length': String(slice.length),
|
|
585
|
+
'Content-Range': `bytes ${start}-${end}/${totalSize}`,
|
|
586
|
+
'Accept-Ranges': 'bytes',
|
|
587
|
+
'Cache-Control': 'no-store',
|
|
588
|
+
'Access-Control-Allow-Origin': '*',
|
|
589
|
+
})
|
|
590
|
+
res.end(slice)
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
res.writeHead(200, {
|
|
595
|
+
'Content-Type': mime,
|
|
596
|
+
'Content-Length': String(totalSize),
|
|
597
|
+
'Accept-Ranges': 'bytes',
|
|
598
|
+
'Cache-Control': 'no-store',
|
|
599
|
+
'Access-Control-Allow-Origin': '*',
|
|
600
|
+
})
|
|
601
|
+
res.end(bytes)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// GET /api/tts/play/:session — unauthenticated, set as audio.src by the client.
|
|
605
|
+
//
|
|
606
|
+
// Cache hit: serves the cached body with Content-Length + Accept-Ranges so
|
|
607
|
+
// iOS's audio engine can range-fetch its play buffer (the ~10s truncation in
|
|
608
|
+
// v5.9.3 was caused by the absence of these headers + a one-shot session).
|
|
609
|
+
//
|
|
610
|
+
// Cache miss (still v5.9.3 buffered behavior): we fully drain OpenAI's audio
|
|
611
|
+
// body into memory, populate the cache, THEN respond with Content-Length set.
|
|
612
|
+
// Why not stream straight through? iOS WKWebView's audio engine refuses to
|
|
613
|
+
// start playback when neither Content-Length nor Range is available, so the
|
|
614
|
+
// v5.9.2 chunked-transfer-encoding path went silent on iOS. Buffering trades
|
|
615
|
+
// back the progressive-download latency, but it actually plays — and the
|
|
616
|
+
// dual-purpose write into the cache means every subsequent REPLAY of the same
|
|
617
|
+
// (text, voice, format) tuple short-circuits to the instant cache-hit path.
|
|
618
|
+
// True low-latency progressive playback requires per-sentence chunking; punted
|
|
619
|
+
// to a follow-up release.
|
|
620
|
+
ttsRouter.get('/tts/play/:session', async (req, res) => {
|
|
621
|
+
// peekSession (v5.9.4) — non-destructive lookup so iOS WKWebView can issue
|
|
622
|
+
// its routine HTTP Range requests for audio buffer refill without 404ing
|
|
623
|
+
// halfway through a long playback. Sessions still TTL out at 60s.
|
|
624
|
+
const session = peekSession(req.params.session)
|
|
625
|
+
if (!session) {
|
|
626
|
+
return res.status(404).json({ error: 'session expired or unknown' })
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const mime = FORMAT_MIME[session.format] ?? 'audio/mpeg'
|
|
630
|
+
|
|
631
|
+
// Fast path: cache hit. Content-Length + Accept-Ranges lets iOS compute
|
|
632
|
+
// duration immediately, manage its decode buffer, and seek/refill via
|
|
633
|
+
// Range requests over the same session URL.
|
|
634
|
+
const cachedHit = getCached(session.hash)
|
|
635
|
+
if (cachedHit) {
|
|
636
|
+
return serveCachedBody(req, res, cachedHit.bytes, cachedHit.sizeBytes, mime)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Race-protection path (v5.9.6): if /prepare just kicked off a pre-warm
|
|
640
|
+
// for this hash, the entry is in-flight in the cache. Wait for it to
|
|
641
|
+
// complete instead of starting our own OpenAI call (which would double-bill
|
|
642
|
+
// and race the writer). waitForInFlight returns null immediately if no
|
|
643
|
+
// entry exists, so cold misses fall through with no extra latency.
|
|
644
|
+
const inFlight = await waitForInFlight(session.hash, 30_000)
|
|
645
|
+
if (inFlight) {
|
|
646
|
+
if (res.writableEnded) return
|
|
647
|
+
return serveCachedBody(req, res, inFlight.bytes, inFlight.sizeBytes, mime)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// True cold miss: no cache entry, no pre-warm. Drive OpenAI ourselves.
|
|
651
|
+
// generateIntoCache handles abort/budget/upstream errors and writes into
|
|
652
|
+
// the cache; we serve out of the cache after it completes. Identical
|
|
653
|
+
// behavior to v5.9.5, just refactored through the shared helper.
|
|
654
|
+
const upstreamController = new AbortController()
|
|
655
|
+
res.once('close', () => {
|
|
656
|
+
// Client bailed before we wrote a response (e.g. user toggled SPEAK off
|
|
657
|
+
// mid-generation, or REPLAY was cancelled). Tear down the upstream
|
|
658
|
+
// OpenAI request — abortEntry inside generateIntoCache rolls back the
|
|
659
|
+
// cache slot so the next request for this hash regenerates from scratch.
|
|
660
|
+
if (!res.writableEnded) upstreamController.abort()
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
const result = await generateIntoCache(
|
|
664
|
+
session.hash,
|
|
665
|
+
session.text,
|
|
666
|
+
session.voice,
|
|
667
|
+
session.format,
|
|
668
|
+
upstreamController.signal,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
if (!result.ok) {
|
|
672
|
+
if (result.status !== 499 && result.status !== 502) {
|
|
673
|
+
// 499 = client hung up, already handled. 502 includes drain errors
|
|
674
|
+
// we already log inside generateIntoCache for non-aborts.
|
|
675
|
+
console.error('[tts/play] generateIntoCache failed:', result.status, result.message)
|
|
676
|
+
}
|
|
677
|
+
if (!res.headersSent) {
|
|
678
|
+
return res.status(result.status === 499 ? 499 : (result.status || 502))
|
|
679
|
+
.json({ error: result.message })
|
|
680
|
+
} else if (!res.writableEnded) {
|
|
681
|
+
return res.end()
|
|
682
|
+
}
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (res.writableEnded) return
|
|
687
|
+
|
|
688
|
+
const served = getCached(session.hash)
|
|
689
|
+
if (!served) {
|
|
690
|
+
// Vanishingly unlikely — generateIntoCache reported ok but the entry was
|
|
691
|
+
// evicted between completeEntry and our read. Fall through with a 502
|
|
692
|
+
// so the client can REPLAY (which will regenerate cleanly).
|
|
693
|
+
return res.status(502).json({ error: 'cache entry vanished post-write' })
|
|
694
|
+
}
|
|
695
|
+
serveCachedBody(req, res, served.bytes, served.sizeBytes, mime)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
// GET /api/tts/budget — diagnostics for the daily TTS spend + cache stats.
|
|
699
|
+
ttsRouter.get('/tts/budget', async (_req, res) => {
|
|
700
|
+
try {
|
|
701
|
+
const { getOpenAITtsBudgetState } = await import('../lib/openai-tts-budget.js')
|
|
702
|
+
res.json({
|
|
703
|
+
...getOpenAITtsBudgetState(),
|
|
704
|
+
cache: getCacheStats(),
|
|
705
|
+
})
|
|
706
|
+
} catch (err) {
|
|
707
|
+
res.status(500).json({ error: errMsg(err) })
|
|
708
|
+
}
|
|
709
|
+
})
|