@gotcos/glasses-server 6.14.0 → 6.15.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.
@@ -13,11 +13,12 @@
13
13
 
14
14
  import { Router } from 'express'
15
15
  import { errMsg } from '../lib/utils.js'
16
- import { getOpenAIKey } from '../lib/openai-key.js'
16
+ import { getOpenAIKey, tryGetOpenAIKey } from '../lib/openai-key.js'
17
17
  import {
18
18
  assertOpenAITtsBudget,
19
19
  recordOpenAITtsUsage,
20
20
  OpenAITtsBudgetExhaustedError,
21
+ getOpenAITtsBudgetState,
21
22
  } from '../lib/openai-tts-budget.js'
22
23
  import {
23
24
  hashKey,
@@ -28,10 +29,32 @@ import {
28
29
  abortEntry,
29
30
  createSession,
30
31
  peekSession,
32
+ rebindSessionHash,
31
33
  reapExpiredSessions,
32
34
  waitForInFlight,
33
35
  getCacheStats,
34
36
  } from '../lib/tts-cache.js'
37
+ import {
38
+ canFallbackToLocal,
39
+ canFallbackToOpenAI,
40
+ decideInitialBackend,
41
+ getTtsEngineMode,
42
+ isKokoroVoiceId,
43
+ isOpenAIVoiceId,
44
+ KOKORO_VOICE_OPTIONS,
45
+ mapOpenAIVoiceToLocal,
46
+ OPENAI_VOICE_OPTIONS,
47
+ type TtsEnginePreference,
48
+ type TtsRouteDecision,
49
+ } from '../lib/tts-engine.js'
50
+ import {
51
+ isLocalTtsReady,
52
+ synthesizeLocalTts,
53
+ recordLocalTtsFallbackToOpenAI,
54
+ } from '../lib/tts-local.js'
55
+ import { applyLocalPronunciation, applyOpenAIPronunciation } from '../lib/tts-pronounce.js'
56
+ import { emitDisplay } from '../lib/display-bus.js'
57
+ import { spawn } from 'node:child_process'
35
58
 
36
59
  export const ttsRouter = Router()
37
60
 
@@ -191,24 +214,124 @@ export function splitForFastPrefix(text: string): { prefix: string; tail: string
191
214
  return { prefix, tail }
192
215
  }
193
216
 
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(
217
+ function openaiBudgetOk(): boolean {
218
+ try {
219
+ assertOpenAITtsBudget()
220
+ return true
221
+ } catch (err) {
222
+ if (err instanceof OpenAITtsBudgetExhaustedError) return false
223
+ throw err
224
+ }
225
+ }
226
+
227
+ function resolveDecision(
228
+ requestedVoice: string,
229
+ enginePreference: TtsEnginePreference | null = null,
230
+ ): TtsRouteDecision {
231
+ const localReady = isLocalTtsReady()
232
+ const preferOpenAI = enginePreference === 'openai'
233
+ return decideInitialBackend({
234
+ openaiVoice: requestedVoice,
235
+ openaiKeyPresent: !!tryGetOpenAIKey(),
236
+ openaiBudgetOk: openaiBudgetOk(),
237
+ localReady,
238
+ preferOpenAI,
239
+ enginePreference,
240
+ })
241
+ }
242
+
243
+ /** Settings / API: engine local|kokoro|openai|cloud. Null = daemon default. */
244
+ function parseEnginePreference(body: unknown): TtsEnginePreference | null {
245
+ if (!body || typeof body !== 'object') return null
246
+ const b = body as Record<string, unknown>
247
+ if (typeof b.engine === 'string') {
248
+ const eng = b.engine.trim().toLowerCase()
249
+ if (eng === 'local' || eng === 'kokoro') return 'local'
250
+ if (eng === 'openai' || eng === 'cloud') return 'openai'
251
+ }
252
+ if (b.preferOpenAI === true || b.prefer_openai === true) return 'openai'
253
+ if (b.forceLocal === true || b.force_local === true) return 'local'
254
+ return null
255
+ }
256
+
257
+ function normalizeRequestedVoice(
258
+ voice: unknown,
259
+ enginePreference: TtsEnginePreference | null,
260
+ ): string {
261
+ const raw = typeof voice === 'string' ? voice.trim() : ''
262
+ if (enginePreference === 'local') {
263
+ if (raw && (isKokoroVoiceId(raw) || isOpenAIVoiceId(raw))) return raw
264
+ return 'am_echo'
265
+ }
266
+ if (enginePreference === 'openai') {
267
+ if (raw && isOpenAIVoiceId(raw)) return raw
268
+ return DEFAULT_VOICE
269
+ }
270
+ // Daemon default / legacy clients: accept either catalog.
271
+ if (raw && (isOpenAIVoiceId(raw) || isKokoroVoiceId(raw))) return raw
272
+ return DEFAULT_VOICE
273
+ }
274
+
275
+ function hashForDecision(
276
+ decision: TtsRouteDecision,
277
+ format: string,
278
+ text: string,
279
+ instructions: string,
280
+ ): string {
281
+ const instr = decision.backend === 'openai' ? instructions : undefined
282
+ // Hash the spoken form so operator lexicon updates invalidate stale cache.
283
+ const spoken =
284
+ decision.backend === 'local'
285
+ ? applyLocalPronunciation(text)
286
+ : applyOpenAIPronunciation(text)
287
+ return hashKey(decision.engineTag, decision.backendVoice, format, spoken, instr)
288
+ }
289
+
290
+ /** Incident dedupe — /prepare can resolve the same outage 2–3× (prefix/tail). */
291
+ let lastFallbackNotifyAt = 0
292
+ const FALLBACK_NOTIFY_DEDUPE_MS = 30_000
293
+
294
+ /** Log an attempted escape without mutating the successful-fallback health field. */
295
+ function noteKokoroFallbackPending(reason: string): void {
296
+ console.warn('[tts] Kokoro unavailable; attempting OpenAI fallback:', reason.slice(0, 160))
297
+ }
298
+
299
+ /** User-visible alert after OpenAI fallback audio actually succeeds. */
300
+ function announceKokoroFallbackToOpenAI(reason: string): void {
301
+ recordLocalTtsFallbackToOpenAI(reason)
302
+ const now = Date.now()
303
+ if (now - lastFallbackNotifyAt < FALLBACK_NOTIFY_DEDUPE_MS) {
304
+ console.warn('[tts] fallback notify deduped:', reason.slice(0, 120))
305
+ return
306
+ }
307
+ lastFallbackNotifyAt = now
308
+
309
+ try {
310
+ emitDisplay({
311
+ type: 'tool_status',
312
+ data: { message: 'TTS: Kokoro failed -> OpenAI' },
313
+ })
314
+ } catch { /* display bus optional */ }
315
+ try {
316
+ spawn(
317
+ 'osascript',
318
+ [
319
+ '-e',
320
+ 'display notification "Kokoro TTS failed — using OpenAI" with title "COS Glasses" sound name "Basso"',
321
+ ],
322
+ { stdio: 'ignore', detached: true },
323
+ ).unref()
324
+ } catch { /* notification optional */ }
325
+ }
326
+
327
+ async function generateOpenAIIntoCache(
203
328
  hash: string,
204
329
  text: string,
205
330
  voice: string,
206
331
  format: string,
332
+ instructions: string,
207
333
  signal?: AbortSignal,
208
334
  ): 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
335
  let key: string
213
336
  try {
214
337
  key = getOpenAIKey()
@@ -216,9 +339,16 @@ async function generateIntoCache(
216
339
  return { ok: false, status: 503, message: errMsg(err) }
217
340
  }
218
341
 
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.
342
+ try {
343
+ assertOpenAITtsBudget()
344
+ } catch (err) {
345
+ if (err instanceof OpenAITtsBudgetExhaustedError) {
346
+ return { ok: false, status: 429, message: err.message }
347
+ }
348
+ throw err
349
+ }
350
+
351
+ const spoken = applyOpenAIPronunciation(text)
222
352
  const slot = startEntry(hash, voice, format)
223
353
  if (!slot) {
224
354
  const served = await waitForInFlight(hash, 30_000)
@@ -238,9 +368,9 @@ async function generateIntoCache(
238
368
  body: JSON.stringify({
239
369
  model: 'gpt-4o-mini-tts',
240
370
  voice,
241
- input: text,
371
+ input: spoken,
242
372
  response_format: format,
243
- ...(DEFAULT_INSTRUCTIONS ? { instructions: DEFAULT_INSTRUCTIONS } : {}),
373
+ ...(instructions ? { instructions } : {}),
244
374
  }),
245
375
  signal,
246
376
  })
@@ -269,7 +399,7 @@ async function generateIntoCache(
269
399
  const buf = Buffer.from(value)
270
400
  if (!firstByteSeen) {
271
401
  firstByteSeen = true
272
- recordOpenAITtsUsage(text.length)
402
+ recordOpenAITtsUsage(spoken.length)
273
403
  }
274
404
  appendBytes(hash, buf)
275
405
  }
@@ -288,117 +418,376 @@ async function generateIntoCache(
288
418
  }
289
419
  }
290
420
 
421
+ async function generateLocalIntoCache(
422
+ hash: string,
423
+ text: string,
424
+ voice: string,
425
+ format: string,
426
+ signal?: AbortSignal,
427
+ ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
428
+ if (!isLocalTtsReady()) {
429
+ return { ok: false, status: 503, message: 'local TTS sidecar not ready' }
430
+ }
431
+ const spoken = applyLocalPronunciation(text)
432
+ const slot = startEntry(hash, voice, format)
433
+ if (!slot) {
434
+ const served = await waitForInFlight(hash, 30_000)
435
+ if (served) return { ok: true }
436
+ return { ok: false, status: 502, message: 'in-flight peer failed or timed out' }
437
+ }
438
+ try {
439
+ // Local path ignores COS_VOICE_INSTRUCTIONS / per-request instructions.
440
+ const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal })
441
+ if (!bytes.length) {
442
+ abortEntry(hash)
443
+ return { ok: false, status: 502, message: 'local TTS returned empty body' }
444
+ }
445
+ appendBytes(hash, bytes)
446
+ completeEntry(hash)
447
+ return { ok: true }
448
+ } catch (err) {
449
+ abortEntry(hash)
450
+ if ((err as { name?: string })?.name === 'AbortError') {
451
+ return { ok: false, status: 499, message: 'client closed request' }
452
+ }
453
+ return { ok: false, status: 502, message: `local TTS failed: ${errMsg(err)}` }
454
+ }
455
+ }
456
+
457
+ /** Drain TTS audio into cache for a resolved backend decision. */
458
+ async function generateIntoCache(
459
+ hash: string,
460
+ text: string,
461
+ decision: TtsRouteDecision,
462
+ format: string,
463
+ instructions: string,
464
+ signal?: AbortSignal,
465
+ ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
466
+ if (getCached(hash)) return { ok: true }
467
+ if (decision.backend === 'local') {
468
+ return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal)
469
+ }
470
+ return generateOpenAIIntoCache(
471
+ hash,
472
+ text,
473
+ decision.backendVoice,
474
+ format,
475
+ instructions,
476
+ signal,
477
+ )
478
+ }
479
+
480
+ /** Resolve → hash → generate, with local↔OpenAI fallbacks. */
481
+ async function generateWithFallback(opts: {
482
+ text: string
483
+ openaiVoice: string
484
+ format: string
485
+ instructions: string
486
+ enginePreference?: TtsEnginePreference | null
487
+ signal?: AbortSignal
488
+ sessionId?: string
489
+ }): Promise<{ ok: true; hash: string } | { ok: false; status: number; message: string }> {
490
+ const enginePreference = opts.enginePreference ?? null
491
+ const preferOpenAI = enginePreference === 'openai'
492
+ const forceLocal = enginePreference === 'local'
493
+ let decision: TtsRouteDecision
494
+ try {
495
+ decision = resolveDecision(opts.openaiVoice, enginePreference)
496
+ } catch (err) {
497
+ return { ok: false, status: 503, message: errMsg(err) }
498
+ }
499
+
500
+ // Soft-escape: wanted Kokoro (daemon local_first or forced local) but got OpenAI.
501
+ const softEscapedToOpenAI =
502
+ decision.backend === 'openai' &&
503
+ !preferOpenAI &&
504
+ (forceLocal || getTtsEngineMode() === 'local_first') &&
505
+ !isLocalTtsReady()
506
+ if (softEscapedToOpenAI) {
507
+ noteKokoroFallbackPending(
508
+ forceLocal
509
+ ? 'Local selected but sidecar not ready'
510
+ : 'sidecar not ready at request time',
511
+ )
512
+ }
513
+
514
+ const hash = hashForDecision(decision, opts.format, opts.text, opts.instructions)
515
+ const primary = await generateIntoCache(
516
+ hash,
517
+ opts.text,
518
+ decision,
519
+ opts.format,
520
+ opts.instructions,
521
+ opts.signal,
522
+ )
523
+ if (primary.ok) {
524
+ if (softEscapedToOpenAI) {
525
+ announceKokoroFallbackToOpenAI(
526
+ forceLocal
527
+ ? 'Local selected but sidecar not ready'
528
+ : 'sidecar not ready at request time',
529
+ )
530
+ }
531
+ return { ok: true, hash }
532
+ }
533
+
534
+ const mode = getTtsEngineMode()
535
+ if (
536
+ decision.backend === 'openai' &&
537
+ primary.status !== 499 &&
538
+ canFallbackToLocal(mode, isLocalTtsReady(), preferOpenAI)
539
+ ) {
540
+ console.warn('[tts] OpenAI failed; falling back to local Kokoro:', primary.status, primary.message)
541
+ const localDecision: TtsRouteDecision = {
542
+ backend: 'local',
543
+ engineTag: 'kokoro',
544
+ backendVoice: mapOpenAIVoiceToLocal(opts.openaiVoice),
545
+ openaiVoice: opts.openaiVoice,
546
+ }
547
+ const localHash = hashForDecision(localDecision, opts.format, opts.text, opts.instructions)
548
+ const localResult = await generateIntoCache(
549
+ localHash,
550
+ opts.text,
551
+ localDecision,
552
+ opts.format,
553
+ opts.instructions,
554
+ opts.signal,
555
+ )
556
+ if (localResult.ok) {
557
+ if (opts.sessionId) rebindSessionHash(opts.sessionId, localHash)
558
+ return { ok: true, hash: localHash }
559
+ }
560
+ return localResult
561
+ }
562
+
563
+ if (
564
+ decision.backend === 'local' &&
565
+ primary.status !== 499 &&
566
+ canFallbackToOpenAI(mode, !!tryGetOpenAIKey(), openaiBudgetOk(), forceLocal)
567
+ ) {
568
+ const failReason = `${primary.status}: ${primary.message}`
569
+ noteKokoroFallbackPending(failReason)
570
+ const openaiVoice = isOpenAIVoiceId(opts.openaiVoice) ? opts.openaiVoice : DEFAULT_VOICE
571
+ const openaiDecision: TtsRouteDecision = {
572
+ backend: 'openai',
573
+ engineTag: 'openai',
574
+ backendVoice: openaiVoice,
575
+ openaiVoice: opts.openaiVoice,
576
+ }
577
+ const openaiHash = hashForDecision(openaiDecision, opts.format, opts.text, opts.instructions)
578
+ const openaiResult = await generateIntoCache(
579
+ openaiHash,
580
+ opts.text,
581
+ openaiDecision,
582
+ opts.format,
583
+ opts.instructions,
584
+ opts.signal,
585
+ )
586
+ if (openaiResult.ok) {
587
+ announceKokoroFallbackToOpenAI(failReason)
588
+ if (opts.sessionId) rebindSessionHash(opts.sessionId, openaiHash)
589
+ return { ok: true, hash: openaiHash }
590
+ }
591
+ return openaiResult
592
+ }
593
+
594
+ return primary
595
+ }
596
+
597
+ ttsRouter.get('/tts/voices', (_req, res) => {
598
+ res.json({
599
+ defaultEngine: getTtsEngineMode() === 'openai' || getTtsEngineMode() === 'openai_primary'
600
+ ? 'openai'
601
+ : 'local',
602
+ localReady: isLocalTtsReady(),
603
+ openai: OPENAI_VOICE_OPTIONS,
604
+ local: KOKORO_VOICE_OPTIONS,
605
+ })
606
+ })
607
+
608
+ /** Preserve the legacy /tts/stream first-byte contract for OpenAI-backed
609
+ * playback. Fallback alerts fire only after a real audio byte succeeds. */
610
+ async function streamOpenAIToResponse(
611
+ res: import('express').Response,
612
+ opts: {
613
+ text: string
614
+ voice: string
615
+ format: string
616
+ instructions: string
617
+ signal: AbortSignal
618
+ onFirstByte?: () => void
619
+ },
620
+ ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
621
+ let key: string
622
+ try {
623
+ key = getOpenAIKey()
624
+ assertOpenAITtsBudget()
625
+ } catch (err) {
626
+ if (err instanceof OpenAITtsBudgetExhaustedError) {
627
+ return { ok: false, status: 429, message: err.message }
628
+ }
629
+ return { ok: false, status: 503, message: errMsg(err) }
630
+ }
631
+
632
+ const spoken = applyOpenAIPronunciation(opts.text)
633
+ let upstream: Response
634
+ try {
635
+ upstream = await fetch('https://api.openai.com/v1/audio/speech', {
636
+ method: 'POST',
637
+ headers: {
638
+ Authorization: `Bearer ${key}`,
639
+ 'Content-Type': 'application/json',
640
+ },
641
+ body: JSON.stringify({
642
+ model: 'gpt-4o-mini-tts',
643
+ voice: opts.voice,
644
+ input: spoken,
645
+ response_format: opts.format,
646
+ ...(opts.instructions ? { instructions: opts.instructions } : {}),
647
+ }),
648
+ signal: opts.signal,
649
+ })
650
+ } catch (err) {
651
+ if (opts.signal.aborted || (err as { name?: string })?.name === 'AbortError') {
652
+ return { ok: false, status: 499, message: 'client closed request' }
653
+ }
654
+ return { ok: false, status: 502, message: `OpenAI TTS fetch failed: ${errMsg(err)}` }
655
+ }
656
+ if (!upstream.ok || !upstream.body) {
657
+ const body = await upstream.text().catch(() => '')
658
+ return {
659
+ ok: false,
660
+ status: upstream.status || 502,
661
+ message: `OpenAI TTS ${upstream.status}: ${body.slice(0, 300)}`,
662
+ }
663
+ }
664
+
665
+ res.writeHead(200, {
666
+ 'Content-Type': FORMAT_MIME[opts.format] ?? 'audio/mpeg',
667
+ 'Cache-Control': 'no-cache',
668
+ 'Transfer-Encoding': 'chunked',
669
+ 'X-Accel-Buffering': 'no',
670
+ 'Access-Control-Allow-Origin': '*',
671
+ })
672
+ res.flushHeaders()
673
+ const reader = upstream.body.getReader()
674
+ let firstByte = false
675
+ try {
676
+ while (true) {
677
+ const { done, value } = await reader.read()
678
+ if (done) break
679
+ if (!value?.length) continue
680
+ if (!firstByte) {
681
+ firstByte = true
682
+ recordOpenAITtsUsage(spoken.length)
683
+ opts.onFirstByte?.()
684
+ }
685
+ if (!res.write(Buffer.from(value))) {
686
+ await new Promise<void>((resolve) => res.once('drain', resolve))
687
+ }
688
+ }
689
+ res.end()
690
+ return { ok: true }
691
+ } catch (err) {
692
+ if (!res.writableEnded) res.end()
693
+ if (opts.signal.aborted || (err as { name?: string })?.name === 'AbortError') {
694
+ return { ok: false, status: 499, message: 'client closed request' }
695
+ }
696
+ return { ok: false, status: 502, message: `OpenAI TTS stream failed: ${errMsg(err)}` }
697
+ } finally {
698
+ try { reader.releaseLock() } catch { /* already released */ }
699
+ }
700
+ }
701
+
291
702
  ttsRouter.post('/tts/stream', async (req, res) => {
292
703
  try {
293
- const { text, voice, format, instructions } = req.body ?? {}
704
+ const { text, format, instructions } = req.body ?? {}
705
+ const enginePreference = parseEnginePreference(req.body)
294
706
 
295
707
  if (typeof text !== 'string' || text.trim().length === 0) {
296
708
  return res.status(400).json({ error: 'text is required (non-empty string)' })
297
709
  }
298
710
 
299
- const requestedVoice = typeof voice === 'string' && SUPPORTED_VOICES.has(voice)
300
- ? voice : DEFAULT_VOICE
711
+ const requestedVoice = normalizeRequestedVoice(req.body?.voice, enginePreference)
301
712
  const requestedFormat = typeof format === 'string' && SUPPORTED_FORMATS.has(format)
302
713
  ? format : 'mp3'
303
714
  const requestedInstructions = typeof instructions === 'string' && instructions.trim().length > 0
304
715
  ? instructions : DEFAULT_INSTRUCTIONS
305
716
 
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
717
  const cleaned = stripMarkdownLight(text).trim()
329
718
  const capped = trimToCap(cleaned)
330
- const charCount = capped.length
331
719
 
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
720
  const upstreamController = new AbortController()
335
721
  res.once('close', () => {
336
722
  if (!res.writableEnded) upstreamController.abort()
337
723
  })
338
724
 
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
- }),
725
+ let decision: TtsRouteDecision
726
+ try {
727
+ decision = resolveDecision(requestedVoice, enginePreference)
728
+ } catch (err) {
729
+ return res.status(503).json({ error: errMsg(err) })
730
+ }
731
+
732
+ const streamOpenAI = (fallbackReason?: string) => streamOpenAIToResponse(res, {
733
+ text: capped,
734
+ voice: isOpenAIVoiceId(requestedVoice) ? requestedVoice : DEFAULT_VOICE,
735
+ format: requestedFormat,
736
+ instructions: requestedInstructions,
352
737
  signal: upstreamController.signal,
738
+ ...(fallbackReason
739
+ ? { onFirstByte: () => announceKokoroFallbackToOpenAI(fallbackReason) }
740
+ : {}),
353
741
  })
354
742
 
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
- })
743
+ if (decision.backend === 'openai') {
744
+ const escapedLocalFirst = enginePreference !== 'openai' && getTtsEngineMode() === 'local_first'
745
+ if (escapedLocalFirst) noteKokoroFallbackPending('sidecar not ready at request time')
746
+ const streamed = await streamOpenAI(escapedLocalFirst ? 'sidecar not ready at request time' : undefined)
747
+ if (!streamed.ok && !res.headersSent) {
748
+ return res.status(streamed.status).json({ error: streamed.message })
749
+ }
750
+ return
360
751
  }
361
752
 
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
753
  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()
754
+ const spoken = applyLocalPronunciation(capped)
755
+ const bytes = await synthesizeLocalTts({
756
+ text: spoken,
757
+ voice: decision.backendVoice,
758
+ format: requestedFormat,
759
+ signal: upstreamController.signal,
760
+ })
761
+ if (upstreamController.signal.aborted) return
762
+ res.writeHead(200, {
763
+ 'Content-Type': FORMAT_MIME[requestedFormat] ?? 'audio/mpeg',
764
+ 'Content-Length': String(bytes.length),
765
+ 'Cache-Control': 'no-cache',
766
+ 'Access-Control-Allow-Origin': '*',
767
+ })
768
+ res.end(bytes)
769
+ return
394
770
  } 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))
771
+ if (upstreamController.signal.aborted || (err as { name?: string })?.name === 'AbortError') {
772
+ if (!res.headersSent) res.status(499).json({ error: 'client closed request' })
773
+ return
774
+ }
775
+ const reason = `local TTS failed: ${errMsg(err)}`
776
+ const forceLocal = enginePreference === 'local'
777
+ if (!canFallbackToOpenAI(
778
+ getTtsEngineMode(),
779
+ !!tryGetOpenAIKey(),
780
+ openaiBudgetOk(),
781
+ forceLocal,
782
+ )) {
783
+ return res.status(502).json({ error: reason })
398
784
  }
399
- if (!res.writableEnded) res.end()
400
- } finally {
401
- try { reader.releaseLock() } catch { /* already released */ }
785
+ noteKokoroFallbackPending(reason)
786
+ const streamed = await streamOpenAI(reason)
787
+ if (!streamed.ok && !res.headersSent) {
788
+ return res.status(streamed.status).json({ error: streamed.message })
789
+ }
790
+ return
402
791
  }
403
792
  } catch (err) {
404
793
  if (!res.headersSent) {
@@ -429,105 +818,96 @@ ttsRouter.post('/tts/stream', async (req, res) => {
429
818
  // UUID IS the auth — short-lived (60s) and one-shot.
430
819
  ttsRouter.post('/tts/prepare', async (req, res) => {
431
820
  try {
432
- const { text, voice, format, instructions, fast } = req.body ?? {}
821
+ const { text, format, instructions, fast } = req.body ?? {}
822
+ const enginePreference = parseEnginePreference(req.body)
433
823
 
434
824
  if (typeof text !== 'string' || text.trim().length === 0) {
435
825
  return res.status(400).json({ error: 'text is required (non-empty string)' })
436
826
  }
437
827
 
438
- const requestedVoice = typeof voice === 'string' && SUPPORTED_VOICES.has(voice)
439
- ? voice : DEFAULT_VOICE
828
+ const requestedVoice = normalizeRequestedVoice(req.body?.voice, enginePreference)
440
829
  const requestedFormat = typeof format === 'string' && SUPPORTED_FORMATS.has(format)
441
830
  ? format : 'mp3'
442
831
  const requestedInstructions = typeof instructions === 'string' && instructions.trim().length > 0
443
832
  ? instructions : DEFAULT_INSTRUCTIONS
444
833
  const fastMode = fast === true
445
834
 
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.
835
+ // Fail closed only when NEITHER OpenAI nor local can serve.
836
+ let decision: TtsRouteDecision
450
837
  try {
451
- assertOpenAITtsBudget()
838
+ decision = resolveDecision(requestedVoice, enginePreference)
452
839
  } catch (err) {
453
- if (err instanceof OpenAITtsBudgetExhaustedError) {
840
+ const budget = getOpenAITtsBudgetState()
841
+ if (!tryGetOpenAIKey()) {
842
+ return res.status(503).json({ error: errMsg(err) })
843
+ }
844
+ if (!openaiBudgetOk() && !isLocalTtsReady()) {
454
845
  return res.status(429).json({
455
- error: err.message,
456
- spentTodayUsd: err.spentTodayUsd,
457
- capUsd: err.capUsd,
846
+ error: errMsg(err),
847
+ spentTodayUsd: budget.usdToday,
848
+ capUsd: budget.capUsd,
458
849
  })
459
850
  }
460
- throw err
851
+ return res.status(503).json({ error: errMsg(err) })
461
852
  }
462
853
 
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
854
  const cleaned = stripMarkdownLight(text).trim()
470
855
  const capped = trimToCap(cleaned)
856
+ const preferOpenAI = enginePreference === 'openai'
857
+ const forceLocal = enginePreference === 'local'
471
858
 
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}` })
859
+ const mintAndWarm = (chunk: string) => {
860
+ const hash = hashForDecision(decision, requestedFormat, chunk, requestedInstructions)
861
+ const uuid = createSession({
862
+ hash,
863
+ text: chunk,
864
+ voice: requestedVoice,
865
+ format: requestedFormat,
866
+ preferOpenAI,
867
+ forceLocal,
868
+ })
869
+ // Detached preparation is deliberately local-only. It must never retain
870
+ // authority to spend cloud budget after the client cancels or closes.
871
+ // OpenAI generation (including Kokoro fallback) begins only from the
872
+ // live /play request, whose AbortSignal follows the connected client.
873
+ if (decision.backend === 'local') {
874
+ void generateIntoCache(
875
+ hash,
876
+ chunk,
877
+ decision,
878
+ requestedFormat,
879
+ requestedInstructions,
880
+ ).then((r) => {
881
+ if (!r.ok && r.status !== 499) {
882
+ console.warn('[tts/prepare] local pre-warm failed:', r.status, r.message)
883
+ }
884
+ })
885
+ }
886
+ return { hash, uuid }
479
887
  }
480
888
 
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
- })
889
+ const engineMeta = {
890
+ engine: decision.engineTag,
891
+ backend: decision.backend,
892
+ voice: decision.backendVoice,
893
+ localReady: isLocalTtsReady(),
894
+ }
496
895
 
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
- })
896
+ if (!fastMode) {
897
+ const { uuid } = mintAndWarm(capped)
898
+ return res.json({ url: `/api/tts/play/${uuid}`, ...engineMeta })
899
+ }
506
900
 
901
+ const { prefix, tail } = splitForFastPrefix(capped)
902
+ const prefixMint = mintAndWarm(prefix)
507
903
  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}` })
904
+ return res.json({ url: `/api/tts/play/${prefixMint.uuid}`, ...engineMeta })
511
905
  }
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
-
906
+ const tailMint = mintAndWarm(tail)
528
907
  res.json({
529
- url: `/api/tts/play/${prefixUuid}`,
530
- tailUrl: `/api/tts/play/${tailUuid}`,
908
+ url: `/api/tts/play/${prefixMint.uuid}`,
909
+ tailUrl: `/api/tts/play/${tailMint.uuid}`,
910
+ ...engineMeta,
531
911
  })
532
912
  } catch (err) {
533
913
  res.status(500).json({ error: errMsg(err) })
@@ -647,32 +1027,31 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
647
1027
  return serveCachedBody(req, res, inFlight.bytes, inFlight.sizeBytes, mime)
648
1028
  }
649
1029
 
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.
1030
+ // True cold miss: no cache entry, no pre-warm. Resolve engine + generate
1031
+ // (with openai_primary local fallback). Session hash may rebind on fallback.
654
1032
  const upstreamController = new AbortController()
655
1033
  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
1034
  if (!res.writableEnded) upstreamController.abort()
661
1035
  })
662
1036
 
663
- const result = await generateIntoCache(
664
- session.hash,
665
- session.text,
666
- session.voice,
667
- session.format,
668
- upstreamController.signal,
669
- )
1037
+ const enginePreference: TtsEnginePreference | null = session.forceLocal
1038
+ ? 'local'
1039
+ : session.preferOpenAI
1040
+ ? 'openai'
1041
+ : null
1042
+ const result = await generateWithFallback({
1043
+ text: session.text,
1044
+ openaiVoice: session.voice,
1045
+ format: session.format,
1046
+ instructions: DEFAULT_INSTRUCTIONS,
1047
+ enginePreference,
1048
+ signal: upstreamController.signal,
1049
+ sessionId: req.params.session,
1050
+ })
670
1051
 
671
1052
  if (!result.ok) {
672
1053
  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)
1054
+ console.error('[tts/play] generateWithFallback failed:', result.status, result.message)
676
1055
  }
677
1056
  if (!res.headersSent) {
678
1057
  return res.status(result.status === 499 ? 499 : (result.status || 502))
@@ -685,11 +1064,8 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
685
1064
 
686
1065
  if (res.writableEnded) return
687
1066
 
688
- const served = getCached(session.hash)
1067
+ const served = getCached(result.hash)
689
1068
  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
1069
  return res.status(502).json({ error: 'cache entry vanished post-write' })
694
1070
  }
695
1071
  serveCachedBody(req, res, served.bytes, served.sizeBytes, mime)
@@ -698,9 +1074,11 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
698
1074
  // GET /api/tts/budget — diagnostics for the daily TTS spend + cache stats.
699
1075
  ttsRouter.get('/tts/budget', async (_req, res) => {
700
1076
  try {
701
- const { getOpenAITtsBudgetState } = await import('../lib/openai-tts-budget.js')
1077
+ const { getLocalTtsHealth } = await import('../lib/tts-local.js')
702
1078
  res.json({
703
1079
  ...getOpenAITtsBudgetState(),
1080
+ engineMode: getTtsEngineMode(),
1081
+ tts_local: getLocalTtsHealth(),
704
1082
  cache: getCacheStats(),
705
1083
  })
706
1084
  } catch (err) {