@gotcos/glasses-server 6.12.7 → 6.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ })