@gotcos/glasses-server 6.36.26 → 6.36.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/server/lib/tts-local.ts +164 -3
- package/server/routes/tts.ts +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
## 6.36.27
|
|
2
|
+
|
|
3
|
+
The sidecar renders one request at a time. The server finally acts like it.
|
|
4
|
+
|
|
5
|
+
WHAT BROKE. Chunking (6.36.25) split a reply into 9 segments and /prepare
|
|
6
|
+
pre-warmed all 9 at once. The synthesis timeout was armed when a request was
|
|
7
|
+
ISSUED, so it ran while that request sat in the sidecar's queue. Measured on a
|
|
8
|
+
6,781-character reply: renders took ~2.6s each, but segment 5 spent 11.5s of its
|
|
9
|
+
12,000ms budget waiting for a turn. On device it tipped over, the pre-warm
|
|
10
|
+
returned 502, and iOS surfaced it as NotSupportedError. Five of nine segments
|
|
11
|
+
played.
|
|
12
|
+
|
|
13
|
+
The 12,000ms constant was not wrong when it was written -- its own comment says
|
|
14
|
+
it exists to "bound hung sidecar so local_first can fall back before session TTL
|
|
15
|
+
(~60s)", from an era when a reply was ONE render. Chunking changed the input and
|
|
16
|
+
nothing re-derived the limit.
|
|
17
|
+
|
|
18
|
+
- New render gate in tts-local.ts. One render reaches the sidecar at a time, so
|
|
19
|
+
the synthesis timeout now bounds RENDER, which is what it always claimed to
|
|
20
|
+
bound. Queue wait is governed separately.
|
|
21
|
+
- Queue wait has its own DERIVED ceiling: one synthesis budget per render ahead,
|
|
22
|
+
plus one budget of headroom. The headroom is not slack -- without it the first
|
|
23
|
+
waiter's ceiling expires in a dead heat with the holder's own timeout, and a
|
|
24
|
+
request that was about to be served is rejected in the same tick. Found by the
|
|
25
|
+
test, not by reasoning.
|
|
26
|
+
- /play outranks /prepare pre-warm. A user is waiting on the first and nobody is
|
|
27
|
+
waiting on the second; without priority, segment 5's playback request queues
|
|
28
|
+
behind pre-warms for 6, 7 and 8 -- work not needed for minutes. Priority never
|
|
29
|
+
reorders playback against itself.
|
|
30
|
+
- /api/health tts_local now reports renderQueueDepth. This was not observable on
|
|
31
|
+
2026-08-23 and that cost an evening.
|
|
32
|
+
|
|
33
|
+
Every guard mutation-verified, including one that only counting could catch:
|
|
34
|
+
dropping the waiter's detach left all ten behaviour tests green while leaking an
|
|
35
|
+
abort listener per queued render (45 of them on a 46-segment reply).
|
|
36
|
+
|
|
37
|
+
217 files, 3042 tests.
|
|
38
|
+
|
|
1
39
|
## 6.36.26
|
|
2
40
|
|
|
3
41
|
Deadline hardening for the segmented TTS path shipped in 6.36.25. Three constants
|
package/package.json
CHANGED
package/server/lib/tts-local.ts
CHANGED
|
@@ -269,6 +269,11 @@ export function getLocalTtsHealth(): {
|
|
|
269
269
|
starting: boolean
|
|
270
270
|
error: string | null
|
|
271
271
|
lastFallbackToOpenAI: { at: string; reason: string } | null
|
|
272
|
+
/** Renders in flight plus queued. A reply is chunked into up to MAX_CHUNKS
|
|
273
|
+
* segments that all pre-warm at once, and the sidecar renders one at a time,
|
|
274
|
+
* so this is the number that explains a slow or failing segment. It was not
|
|
275
|
+
* observable on 2026-08-23 and that cost an evening. */
|
|
276
|
+
renderQueueDepth: number
|
|
272
277
|
} {
|
|
273
278
|
return {
|
|
274
279
|
ready: serverAvailable,
|
|
@@ -282,6 +287,7 @@ export function getLocalTtsHealth(): {
|
|
|
282
287
|
starting: serverStarting,
|
|
283
288
|
error: lastError,
|
|
284
289
|
lastFallbackToOpenAI,
|
|
290
|
+
renderQueueDepth: localRenderQueueDepth(),
|
|
285
291
|
}
|
|
286
292
|
}
|
|
287
293
|
|
|
@@ -390,15 +396,170 @@ export function stopLocalTtsServer(): void {
|
|
|
390
396
|
}
|
|
391
397
|
|
|
392
398
|
/** Synthesize via local OpenAI-shaped speech endpoint. Returns full audio Buffer. */
|
|
399
|
+
|
|
400
|
+
// ── render gate ──────────────────────────────────────────────────────────────
|
|
401
|
+
//
|
|
402
|
+
// The Kokoro sidecar renders ONE request at a time behind its own lock. Issuing
|
|
403
|
+
// N requests concurrently therefore does not make them finish any sooner; it
|
|
404
|
+
// only makes each one's timeout run while it waits its turn.
|
|
405
|
+
//
|
|
406
|
+
// That is what broke playback on 2026-08-23. Chunking turned a reply into 9
|
|
407
|
+
// renders, all fired at once by /prepare's pre-warm. Measured on a 6,781-char
|
|
408
|
+
// reply: each render took ~2.6s, but segment 5's request spent 11.5s of its
|
|
409
|
+
// 12,000ms budget QUEUED. On a slightly busier machine it tipped over, returned
|
|
410
|
+
// 502, and iOS surfaced it as NotSupportedError. Five of nine segments played.
|
|
411
|
+
//
|
|
412
|
+
// The gate makes that queue explicit on our side, which buys two things:
|
|
413
|
+
//
|
|
414
|
+
// 1. The synthesis timeout starts when a request ACQUIRES the gate, so it
|
|
415
|
+
// bounds RENDER time -- what it was always described as bounding. Waiting
|
|
416
|
+
// for a turn is governed separately, by a derived ceiling (below).
|
|
417
|
+
// 2. Playback jumps ahead of pre-warm. A /play request has a user waiting on
|
|
418
|
+
// it; a /prepare pre-warm does not. Without priority, segment 5's playback
|
|
419
|
+
// request queues behind the pre-warms for 6, 7 and 8 -- work that is not
|
|
420
|
+
// needed until minutes later.
|
|
421
|
+
//
|
|
422
|
+
// Priority never reorders playback against itself: a priority waiter is placed
|
|
423
|
+
// after other priority waiters and before every background one.
|
|
424
|
+
|
|
425
|
+
type RenderWaiter = {
|
|
426
|
+
priority: boolean
|
|
427
|
+
resolve: () => void
|
|
428
|
+
reject: (err: Error) => void
|
|
429
|
+
signal?: AbortSignal
|
|
430
|
+
detach?: () => void
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let gateBusy = false
|
|
434
|
+
const gateWaiters: RenderWaiter[] = []
|
|
435
|
+
|
|
436
|
+
/** In-flight plus queued renders. Exposed for /api/health and tests. */
|
|
437
|
+
export function localRenderQueueDepth(): number {
|
|
438
|
+
return gateWaiters.length + (gateBusy ? 1 : 0)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Test-only: drop all queued waiters and free the gate. */
|
|
442
|
+
export function __resetRenderGate(): void {
|
|
443
|
+
for (const w of gateWaiters.splice(0)) {
|
|
444
|
+
w.detach?.()
|
|
445
|
+
w.reject(makeAbortError('render gate reset'))
|
|
446
|
+
}
|
|
447
|
+
gateBusy = false
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function makeAbortError(message: string): Error {
|
|
451
|
+
const err = new Error(message)
|
|
452
|
+
err.name = 'AbortError'
|
|
453
|
+
return err
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function releaseRenderGate(): void {
|
|
457
|
+
const next = gateWaiters.shift()
|
|
458
|
+
if (!next) {
|
|
459
|
+
gateBusy = false
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
// The gate stays held; ownership passes straight to `next`. Setting
|
|
463
|
+
// gateBusy = false here would let a later arrival barge in ahead of a
|
|
464
|
+
// waiter that has already been promised the slot.
|
|
465
|
+
next.detach?.()
|
|
466
|
+
next.resolve()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function acquireRenderGate(priority: boolean, signal?: AbortSignal): Promise<void> {
|
|
470
|
+
if (signal?.aborted) return Promise.reject(makeAbortError('local TTS request aborted'))
|
|
471
|
+
if (!gateBusy) {
|
|
472
|
+
gateBusy = true
|
|
473
|
+
return Promise.resolve()
|
|
474
|
+
}
|
|
475
|
+
return new Promise<void>((resolve, reject) => {
|
|
476
|
+
const waiter: RenderWaiter = { priority, resolve, reject, signal }
|
|
477
|
+
|
|
478
|
+
// DERIVED ceiling on queue wait, so a wedged sidecar cannot hang callers
|
|
479
|
+
// forever. Everyone ahead of us is individually bounded by the synthesis
|
|
480
|
+
// timeout, so the longest legitimate wait is that timeout once per render
|
|
481
|
+
// ahead of us -- the one in flight plus everyone already queued.
|
|
482
|
+
//
|
|
483
|
+
// The +1 is not slack, it is a race fix found by the test below. Without it
|
|
484
|
+
// the first waiter's ceiling is exactly one render budget, which expires in
|
|
485
|
+
// a dead heat with the holder's OWN timeout: the holder times out, releases
|
|
486
|
+
// the gate, and the waiter that was about to be served is rejected in the
|
|
487
|
+
// same tick. One extra budget of headroom means the queue always outlives
|
|
488
|
+
// the thing it is waiting for.
|
|
489
|
+
const ahead = gateWaiters.length + 1
|
|
490
|
+
const waitCeilingMs = (ahead + 1) * effectiveSynthTimeoutMs()
|
|
491
|
+
const timer = setTimeout(() => {
|
|
492
|
+
const at = gateWaiters.indexOf(waiter)
|
|
493
|
+
if (at >= 0) gateWaiters.splice(at, 1)
|
|
494
|
+
waiter.detach?.()
|
|
495
|
+
const err = new Error(
|
|
496
|
+
`local TTS queue wait exceeded ${waitCeilingMs}ms behind ${ahead} render(s)`,
|
|
497
|
+
)
|
|
498
|
+
err.name = 'TimeoutError'
|
|
499
|
+
reject(err)
|
|
500
|
+
}, waitCeilingMs)
|
|
501
|
+
if (typeof timer === 'object' && timer && 'unref' in timer) {
|
|
502
|
+
;(timer as { unref: () => void }).unref()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const onAbort = () => {
|
|
506
|
+
const at = gateWaiters.indexOf(waiter)
|
|
507
|
+
if (at >= 0) gateWaiters.splice(at, 1)
|
|
508
|
+
waiter.detach?.()
|
|
509
|
+
reject(makeAbortError('local TTS request aborted'))
|
|
510
|
+
}
|
|
511
|
+
waiter.detach = () => {
|
|
512
|
+
clearTimeout(timer)
|
|
513
|
+
signal?.removeEventListener('abort', onAbort)
|
|
514
|
+
}
|
|
515
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
516
|
+
|
|
517
|
+
if (priority) {
|
|
518
|
+
const firstBackground = gateWaiters.findIndex((w) => !w.priority)
|
|
519
|
+
if (firstBackground < 0) gateWaiters.push(waiter)
|
|
520
|
+
else gateWaiters.splice(firstBackground, 0, waiter)
|
|
521
|
+
} else {
|
|
522
|
+
gateWaiters.push(waiter)
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function effectiveSynthTimeoutMs(): number {
|
|
528
|
+
return Number.isFinite(LOCAL_TTS_SYNTH_TIMEOUT_MS) && LOCAL_TTS_SYNTH_TIMEOUT_MS > 0
|
|
529
|
+
? LOCAL_TTS_SYNTH_TIMEOUT_MS
|
|
530
|
+
: 12_000
|
|
531
|
+
}
|
|
532
|
+
|
|
393
533
|
export async function synthesizeLocalTts(opts: {
|
|
394
534
|
text: string
|
|
395
535
|
voice: string
|
|
396
536
|
format: string
|
|
397
537
|
signal?: AbortSignal
|
|
538
|
+
/** True for a request a user is waiting on (/play). False/undefined for
|
|
539
|
+
* background pre-warm, which yields the sidecar to playback. */
|
|
540
|
+
priority?: boolean
|
|
398
541
|
}): Promise<Buffer> {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
542
|
+
// Wait for the sidecar BEFORE starting the synthesis clock. The timeout
|
|
543
|
+
// below is a render budget; it was previously charged for queue time too,
|
|
544
|
+
// which is the whole reason segment 5 of 9 died at 12s while rendering in
|
|
545
|
+
// 2.6s. See the render gate above.
|
|
546
|
+
await acquireRenderGate(opts.priority === true, opts.signal)
|
|
547
|
+
try {
|
|
548
|
+
return await synthesizeLocalTtsRender(opts)
|
|
549
|
+
} finally {
|
|
550
|
+
releaseRenderGate()
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The actual sidecar call. Never invoke directly -- it assumes the caller
|
|
555
|
+
* holds the render gate, and the timeout it starts is a RENDER budget. */
|
|
556
|
+
async function synthesizeLocalTtsRender(opts: {
|
|
557
|
+
text: string
|
|
558
|
+
voice: string
|
|
559
|
+
format: string
|
|
560
|
+
signal?: AbortSignal
|
|
561
|
+
}): Promise<Buffer> {
|
|
562
|
+
const timeoutMs = effectiveSynthTimeoutMs()
|
|
402
563
|
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
403
564
|
const signal =
|
|
404
565
|
opts.signal && typeof AbortSignal.any === 'function'
|
package/server/routes/tts.ts
CHANGED
|
@@ -454,6 +454,9 @@ async function generateLocalIntoCache(
|
|
|
454
454
|
voice: string,
|
|
455
455
|
format: string,
|
|
456
456
|
signal?: AbortSignal,
|
|
457
|
+
// A user is waiting on a /play render; nobody is waiting on a pre-warm.
|
|
458
|
+
// Defaults to background so a new caller cannot accidentally starve playback.
|
|
459
|
+
priority = false,
|
|
457
460
|
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
458
461
|
// A memory/latency bound, NOT OpenAI's 4096. Kokoro reads what it is handed;
|
|
459
462
|
// the old shared cap truncated local speech at ~3-4 pages for no reason that
|
|
@@ -471,7 +474,7 @@ async function generateLocalIntoCache(
|
|
|
471
474
|
}
|
|
472
475
|
try {
|
|
473
476
|
// Local path ignores COS_VOICE_INSTRUCTIONS / per-request instructions.
|
|
474
|
-
const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal })
|
|
477
|
+
const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal, priority })
|
|
475
478
|
if (!bytes.length) {
|
|
476
479
|
abortEntry(hash)
|
|
477
480
|
return { ok: false, status: 502, message: 'local TTS returned empty body' }
|
|
@@ -496,10 +499,11 @@ async function generateIntoCache(
|
|
|
496
499
|
format: string,
|
|
497
500
|
instructions: string,
|
|
498
501
|
signal?: AbortSignal,
|
|
502
|
+
priority = false,
|
|
499
503
|
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
500
504
|
if (getCached(hash)) return { ok: true }
|
|
501
505
|
if (decision.backend === 'local') {
|
|
502
|
-
return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal)
|
|
506
|
+
return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal, priority)
|
|
503
507
|
}
|
|
504
508
|
return generateOpenAIIntoCache(
|
|
505
509
|
hash,
|
|
@@ -520,6 +524,9 @@ async function generateWithFallback(opts: {
|
|
|
520
524
|
enginePreference?: TtsEnginePreference | null
|
|
521
525
|
signal?: AbortSignal
|
|
522
526
|
sessionId?: string
|
|
527
|
+
/** True when a user is waiting (a /play cold miss). Background pre-warm
|
|
528
|
+
* leaves it false so playback can take the sidecar ahead of it. */
|
|
529
|
+
priority?: boolean
|
|
523
530
|
}): Promise<{ ok: true; hash: string } | { ok: false; status: number; message: string }> {
|
|
524
531
|
const enginePreference = opts.enginePreference ?? null
|
|
525
532
|
const preferOpenAI = enginePreference === 'openai'
|
|
@@ -553,6 +560,7 @@ async function generateWithFallback(opts: {
|
|
|
553
560
|
opts.format,
|
|
554
561
|
opts.instructions,
|
|
555
562
|
opts.signal,
|
|
563
|
+
opts.priority === true,
|
|
556
564
|
)
|
|
557
565
|
if (primary.ok) {
|
|
558
566
|
if (softEscapedToOpenAI) {
|
|
@@ -586,6 +594,7 @@ async function generateWithFallback(opts: {
|
|
|
586
594
|
opts.format,
|
|
587
595
|
opts.instructions,
|
|
588
596
|
opts.signal,
|
|
597
|
+
opts.priority === true,
|
|
589
598
|
)
|
|
590
599
|
if (localResult.ok) {
|
|
591
600
|
if (opts.sessionId) rebindSessionHash(opts.sessionId, localHash)
|
|
@@ -616,6 +625,7 @@ async function generateWithFallback(opts: {
|
|
|
616
625
|
opts.format,
|
|
617
626
|
opts.instructions,
|
|
618
627
|
opts.signal,
|
|
628
|
+
opts.priority === true,
|
|
619
629
|
)
|
|
620
630
|
if (openaiResult.ok) {
|
|
621
631
|
announceKokoroFallbackToOpenAI(failReason)
|
|
@@ -803,6 +813,7 @@ ttsRouter.post('/tts/stream', async (req, res) => {
|
|
|
803
813
|
voice: decision.backendVoice,
|
|
804
814
|
format: requestedFormat,
|
|
805
815
|
signal: upstreamController.signal,
|
|
816
|
+
priority: true,
|
|
806
817
|
})
|
|
807
818
|
if (upstreamController.signal.aborted) return
|
|
808
819
|
res.writeHead(200, {
|
|
@@ -1112,6 +1123,8 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
|
|
|
1112
1123
|
enginePreference,
|
|
1113
1124
|
signal: upstreamController.signal,
|
|
1114
1125
|
sessionId: req.params.session,
|
|
1126
|
+
// /play cold miss: the listener is waiting on this render right now.
|
|
1127
|
+
priority: true,
|
|
1115
1128
|
})
|
|
1116
1129
|
|
|
1117
1130
|
if (!result.ok) {
|