@gotcos/glasses-server 6.14.1 → 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.
- package/.env.example +11 -0
- package/CHANGELOG.md +27 -0
- package/README.md +29 -7
- package/bin/cli.cjs +17 -4
- package/package.json +2 -2
- package/server/index.ts +3 -0
- package/server/lib/tts-cache.ts +28 -2
- package/server/lib/tts-engine.ts +230 -0
- package/server/lib/tts-local.ts +375 -0
- package/server/lib/tts-pronounce.ts +53 -0
- package/server/lib/whisper-local.ts +86 -4
- package/server/routes/health.ts +8 -2
- package/server/routes/tts.ts +574 -196
- package/server/tts-sidecar/bootstrap.sh +33 -0
- package/server/tts-sidecar/requirements.txt +9 -0
- package/server/tts-sidecar/server.py +235 -0
package/server/routes/tts.ts
CHANGED
|
@@ -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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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:
|
|
371
|
+
input: spoken,
|
|
242
372
|
response_format: format,
|
|
243
|
-
...(
|
|
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(
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 (
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
//
|
|
447
|
-
|
|
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
|
-
|
|
838
|
+
decision = resolveDecision(requestedVoice, enginePreference)
|
|
452
839
|
} catch (err) {
|
|
453
|
-
|
|
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
|
|
456
|
-
spentTodayUsd:
|
|
457
|
-
capUsd:
|
|
846
|
+
error: errMsg(err),
|
|
847
|
+
spentTodayUsd: budget.usdToday,
|
|
848
|
+
capUsd: budget.capUsd,
|
|
458
849
|
})
|
|
459
850
|
}
|
|
460
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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/${
|
|
530
|
-
tailUrl: `/api/tts/play/${
|
|
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.
|
|
651
|
-
//
|
|
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
|
|
664
|
-
|
|
665
|
-
session.
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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) {
|