@gotcos/glasses-server 6.14.1 → 6.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/CHANGELOG.md +40 -0
- package/README.md +31 -7
- package/bin/cli.cjs +31 -5
- package/package.json +2 -2
- package/server/index.ts +10 -19
- package/server/lib/api-auth.ts +34 -0
- package/server/lib/tts-cache.ts +32 -5
- 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 +579 -199
- package/server/tts-sidecar/bootstrap.sh +57 -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) {
|
|
@@ -421,113 +810,106 @@ ttsRouter.post('/tts/stream', async (req, res) => {
|
|
|
421
810
|
// 1. Client POSTs {text, voice, format} here. We strip+trim+budget-check,
|
|
422
811
|
// hash the (text, voice, format) tuple, and return a session URL.
|
|
423
812
|
// 2. Client sets audio.src = `${apiBase}${sessionUrl}` and calls .play().
|
|
424
|
-
// 3. The browser GETs /api/tts/play/:session
|
|
425
|
-
//
|
|
813
|
+
// 3. The browser GETs /api/tts/play/:session using the session as a bearer
|
|
814
|
+
// capability. Range refills may reuse it during its 60-second lifetime;
|
|
815
|
+
// the route serves cached bytes or starts live generation on a cold miss.
|
|
426
816
|
//
|
|
427
817
|
// The two-step pattern is required because authentication on the play route
|
|
428
818
|
// would force XHR (no Range support, no progressive decoding). The session
|
|
429
|
-
// UUID IS the auth — short-lived (60s) and
|
|
819
|
+
// UUID IS the auth — cryptographically random, short-lived (60s), and scoped
|
|
820
|
+
// to one prepared audio item. It is re-readable only for native Range refills.
|
|
430
821
|
ttsRouter.post('/tts/prepare', async (req, res) => {
|
|
431
822
|
try {
|
|
432
|
-
const { text,
|
|
823
|
+
const { text, format, instructions, fast } = req.body ?? {}
|
|
824
|
+
const enginePreference = parseEnginePreference(req.body)
|
|
433
825
|
|
|
434
826
|
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
435
827
|
return res.status(400).json({ error: 'text is required (non-empty string)' })
|
|
436
828
|
}
|
|
437
829
|
|
|
438
|
-
const requestedVoice =
|
|
439
|
-
? voice : DEFAULT_VOICE
|
|
830
|
+
const requestedVoice = normalizeRequestedVoice(req.body?.voice, enginePreference)
|
|
440
831
|
const requestedFormat = typeof format === 'string' && SUPPORTED_FORMATS.has(format)
|
|
441
832
|
? format : 'mp3'
|
|
442
833
|
const requestedInstructions = typeof instructions === 'string' && instructions.trim().length > 0
|
|
443
834
|
? instructions : DEFAULT_INSTRUCTIONS
|
|
444
835
|
const fastMode = fast === true
|
|
445
836
|
|
|
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.
|
|
837
|
+
// Fail closed only when NEITHER OpenAI nor local can serve.
|
|
838
|
+
let decision: TtsRouteDecision
|
|
450
839
|
try {
|
|
451
|
-
|
|
840
|
+
decision = resolveDecision(requestedVoice, enginePreference)
|
|
452
841
|
} catch (err) {
|
|
453
|
-
|
|
842
|
+
const budget = getOpenAITtsBudgetState()
|
|
843
|
+
if (!tryGetOpenAIKey()) {
|
|
844
|
+
return res.status(503).json({ error: errMsg(err) })
|
|
845
|
+
}
|
|
846
|
+
if (!openaiBudgetOk() && !isLocalTtsReady()) {
|
|
454
847
|
return res.status(429).json({
|
|
455
|
-
error: err
|
|
456
|
-
spentTodayUsd:
|
|
457
|
-
capUsd:
|
|
848
|
+
error: errMsg(err),
|
|
849
|
+
spentTodayUsd: budget.usdToday,
|
|
850
|
+
capUsd: budget.capUsd,
|
|
458
851
|
})
|
|
459
852
|
}
|
|
460
|
-
|
|
853
|
+
return res.status(503).json({ error: errMsg(err) })
|
|
461
854
|
}
|
|
462
855
|
|
|
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
856
|
const cleaned = stripMarkdownLight(text).trim()
|
|
470
857
|
const capped = trimToCap(cleaned)
|
|
858
|
+
const preferOpenAI = enginePreference === 'openai'
|
|
859
|
+
const forceLocal = enginePreference === 'local'
|
|
471
860
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
861
|
+
const mintAndWarm = (chunk: string) => {
|
|
862
|
+
const hash = hashForDecision(decision, requestedFormat, chunk, requestedInstructions)
|
|
863
|
+
const uuid = createSession({
|
|
864
|
+
hash,
|
|
865
|
+
text: chunk,
|
|
866
|
+
voice: requestedVoice,
|
|
867
|
+
format: requestedFormat,
|
|
868
|
+
preferOpenAI,
|
|
869
|
+
forceLocal,
|
|
870
|
+
})
|
|
871
|
+
// Detached preparation is deliberately local-only. It must never retain
|
|
872
|
+
// authority to spend cloud budget after the client cancels or closes.
|
|
873
|
+
// OpenAI generation (including Kokoro fallback) begins only from the
|
|
874
|
+
// live /play request, whose AbortSignal follows the connected client.
|
|
875
|
+
if (decision.backend === 'local') {
|
|
876
|
+
void generateIntoCache(
|
|
877
|
+
hash,
|
|
878
|
+
chunk,
|
|
879
|
+
decision,
|
|
880
|
+
requestedFormat,
|
|
881
|
+
requestedInstructions,
|
|
882
|
+
).then((r) => {
|
|
883
|
+
if (!r.ok && r.status !== 499) {
|
|
884
|
+
console.warn('[tts/prepare] local pre-warm failed:', r.status, r.message)
|
|
885
|
+
}
|
|
886
|
+
})
|
|
887
|
+
}
|
|
888
|
+
return { hash, uuid }
|
|
479
889
|
}
|
|
480
890
|
|
|
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
|
-
})
|
|
891
|
+
const engineMeta = {
|
|
892
|
+
engine: decision.engineTag,
|
|
893
|
+
backend: decision.backend,
|
|
894
|
+
voice: decision.backendVoice,
|
|
895
|
+
localReady: isLocalTtsReady(),
|
|
896
|
+
}
|
|
496
897
|
|
|
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
|
-
})
|
|
898
|
+
if (!fastMode) {
|
|
899
|
+
const { uuid } = mintAndWarm(capped)
|
|
900
|
+
return res.json({ url: `/api/tts/play/${uuid}`, ...engineMeta })
|
|
901
|
+
}
|
|
506
902
|
|
|
903
|
+
const { prefix, tail } = splitForFastPrefix(capped)
|
|
904
|
+
const prefixMint = mintAndWarm(prefix)
|
|
507
905
|
if (tail.length === 0) {
|
|
508
|
-
|
|
509
|
-
// v5.9.5 single-URL playback path automatically.
|
|
510
|
-
return res.json({ url: `/api/tts/play/${prefixUuid}` })
|
|
906
|
+
return res.json({ url: `/api/tts/play/${prefixMint.uuid}`, ...engineMeta })
|
|
511
907
|
}
|
|
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
|
-
|
|
908
|
+
const tailMint = mintAndWarm(tail)
|
|
528
909
|
res.json({
|
|
529
|
-
url: `/api/tts/play/${
|
|
530
|
-
tailUrl: `/api/tts/play/${
|
|
910
|
+
url: `/api/tts/play/${prefixMint.uuid}`,
|
|
911
|
+
tailUrl: `/api/tts/play/${tailMint.uuid}`,
|
|
912
|
+
...engineMeta,
|
|
531
913
|
})
|
|
532
914
|
} catch (err) {
|
|
533
915
|
res.status(500).json({ error: errMsg(err) })
|
|
@@ -647,32 +1029,31 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
|
|
|
647
1029
|
return serveCachedBody(req, res, inFlight.bytes, inFlight.sizeBytes, mime)
|
|
648
1030
|
}
|
|
649
1031
|
|
|
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.
|
|
1032
|
+
// True cold miss: no cache entry, no pre-warm. Resolve engine + generate
|
|
1033
|
+
// (with openai_primary → local fallback). Session hash may rebind on fallback.
|
|
654
1034
|
const upstreamController = new AbortController()
|
|
655
1035
|
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
1036
|
if (!res.writableEnded) upstreamController.abort()
|
|
661
1037
|
})
|
|
662
1038
|
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
session.
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1039
|
+
const enginePreference: TtsEnginePreference | null = session.forceLocal
|
|
1040
|
+
? 'local'
|
|
1041
|
+
: session.preferOpenAI
|
|
1042
|
+
? 'openai'
|
|
1043
|
+
: null
|
|
1044
|
+
const result = await generateWithFallback({
|
|
1045
|
+
text: session.text,
|
|
1046
|
+
openaiVoice: session.voice,
|
|
1047
|
+
format: session.format,
|
|
1048
|
+
instructions: DEFAULT_INSTRUCTIONS,
|
|
1049
|
+
enginePreference,
|
|
1050
|
+
signal: upstreamController.signal,
|
|
1051
|
+
sessionId: req.params.session,
|
|
1052
|
+
})
|
|
670
1053
|
|
|
671
1054
|
if (!result.ok) {
|
|
672
1055
|
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)
|
|
1056
|
+
console.error('[tts/play] generateWithFallback failed:', result.status, result.message)
|
|
676
1057
|
}
|
|
677
1058
|
if (!res.headersSent) {
|
|
678
1059
|
return res.status(result.status === 499 ? 499 : (result.status || 502))
|
|
@@ -685,11 +1066,8 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
|
|
|
685
1066
|
|
|
686
1067
|
if (res.writableEnded) return
|
|
687
1068
|
|
|
688
|
-
const served = getCached(
|
|
1069
|
+
const served = getCached(result.hash)
|
|
689
1070
|
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
1071
|
return res.status(502).json({ error: 'cache entry vanished post-write' })
|
|
694
1072
|
}
|
|
695
1073
|
serveCachedBody(req, res, served.bytes, served.sizeBytes, mime)
|
|
@@ -698,9 +1076,11 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
|
|
|
698
1076
|
// GET /api/tts/budget — diagnostics for the daily TTS spend + cache stats.
|
|
699
1077
|
ttsRouter.get('/tts/budget', async (_req, res) => {
|
|
700
1078
|
try {
|
|
701
|
-
const {
|
|
1079
|
+
const { getLocalTtsHealth } = await import('../lib/tts-local.js')
|
|
702
1080
|
res.json({
|
|
703
1081
|
...getOpenAITtsBudgetState(),
|
|
1082
|
+
engineMode: getTtsEngineMode(),
|
|
1083
|
+
tts_local: getLocalTtsHealth(),
|
|
704
1084
|
cache: getCacheStats(),
|
|
705
1085
|
})
|
|
706
1086
|
} catch (err) {
|