@gotcos/glasses-server 6.36.24 → 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 +112 -0
- package/package.json +1 -1
- package/server/lib/tts-cache.ts +40 -5
- package/server/lib/tts-local.ts +164 -3
- package/server/routes/tts.ts +137 -97
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
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
|
+
|
|
39
|
+
## 6.36.26
|
|
40
|
+
|
|
41
|
+
Deadline hardening for the segmented TTS path shipped in 6.36.25. Three constants
|
|
42
|
+
that were written rather than derived, and three tests that could not fail.
|
|
43
|
+
|
|
44
|
+
- `SESSION_IDLE_MS` 60s -> 120s, DERIVED. Every segment's session is minted at
|
|
45
|
+
`/prepare`, but the client only touches segment i+1 when segment i starts
|
|
46
|
+
playing -- so the idle window has to outlast one full segment at the slowest
|
|
47
|
+
speed the client offers: `900 / 19 / 0.5 = 94.7s`. 60s covered 1x (47.4s) and
|
|
48
|
+
1.25x (37.9s) but not 0.75x (63.2s), which is a shipped option in the Settings
|
|
49
|
+
picker. At 0.75x every other segment would have 404'd, and because the client
|
|
50
|
+
resolves rather than rejects on error, playback would have continued and
|
|
51
|
+
dropped half the reply while still sounding complete.
|
|
52
|
+
- `MAX_CHUNKS` 40 -> 46. 40 covered 35,350 characters against a 40,000-character
|
|
53
|
+
local cap -- 4,650 short, not "comfortably past" as its comment claimed. The
|
|
54
|
+
overflow landed in one oversized final segment, which the OpenAI backend then
|
|
55
|
+
trims PER SEGMENT, silently dropping text and contradicting the chunker's own
|
|
56
|
+
no-loss contract. (46 was the second answer; 45 was still 150 short.)
|
|
57
|
+
- The timing test compared one chunk's render time against its own playback
|
|
58
|
+
time. Both sides are linear in length, so it reduced to `1.9 < 17.54` and
|
|
59
|
+
passed for every input -- including `LATER_CHUNK_CHARS = 100_000`, which
|
|
60
|
+
restores the original bug exactly. Replaced with the cumulative, serialized
|
|
61
|
+
form the sidecar actually exhibits, plus a test that scores the OLD
|
|
62
|
+
prefix/tail split as the failure it was.
|
|
63
|
+
- Session-lifetime and policy tests now import `SESSION_IDLE_MS` and
|
|
64
|
+
`SESSION_MAX_LIFETIME_MS` instead of restating them as literals.
|
|
65
|
+
- `SESSION_MAX_LIFETIME_MS` 30 -> 90 minutes (derived: a 40,000-char reply is
|
|
66
|
+
70.2 minutes at 0.5x).
|
|
67
|
+
|
|
68
|
+
All four constants are mutation-verified: reverting each one fails a test.
|
|
69
|
+
|
|
70
|
+
215 files, 3028 tests.
|
|
71
|
+
|
|
72
|
+
## 6.36.25
|
|
73
|
+
|
|
74
|
+
**Spoken replies are now N segments, not a prefix and a tail.**
|
|
75
|
+
|
|
76
|
+
The two-segment split was a race, and the user lost it by less than a second.
|
|
77
|
+
Measured on device with a 6,781-character reply:
|
|
78
|
+
|
|
79
|
+
prefix rendered in 0.5s
|
|
80
|
+
tail rendered in 12.4s -- AFTER the prefix; the sidecar serializes
|
|
81
|
+
synthesis behind one lock
|
|
82
|
+
tail ready ~12.9s after prepare
|
|
83
|
+
prefix audio 15s ... but 12.0s at the user's 1.25x playback speed
|
|
84
|
+
|
|
85
|
+
The phone asked for the tail at 12.0s. It existed at 12.9s. `/play` blocks until
|
|
86
|
+
synthesis finishes before sending any headers -- 11.3 seconds to first byte,
|
|
87
|
+
measured -- and iOS's media loader will not wait. It buffered nothing and rejected
|
|
88
|
+
with `NotSupportedError`.
|
|
89
|
+
|
|
90
|
+
Widening the margin would not have fixed it: the margin depends on reply length,
|
|
91
|
+
voice, playback speed and machine load. Chunking removes the race instead. The
|
|
92
|
+
first segment stays small (250 chars, ~0.5s render, ~13s of speech) so first audio
|
|
93
|
+
is as fast as before; later segments are 900 chars. Every segment renders roughly
|
|
94
|
+
ten times faster than it plays, so the queue only gets further ahead — a property
|
|
95
|
+
now asserted directly rather than assumed.
|
|
96
|
+
|
|
97
|
+
`/prepare` returns `urls` (every segment, in order) plus `chunks`. `url` and
|
|
98
|
+
`tailUrl` are kept, pointing at the first two, so a client older than 6.8.428
|
|
99
|
+
plays a degraded two segments instead of nothing.
|
|
100
|
+
|
|
101
|
+
`splitForFastPrefix` is deleted — zero callers, zero tests, and leaving a
|
|
102
|
+
superseded splitter next to its replacement is how the wrong one gets used.
|
|
103
|
+
|
|
104
|
+
The chunker's first test asserts that concatenating the segments reproduces the
|
|
105
|
+
input's words. That caught a real defect in the first draft: the final piece was
|
|
106
|
+
appended twice, so `splitForChunks('Hi.')` returned `['Hi. Hi.']`. A chunker that
|
|
107
|
+
duplicates or drops text is worse than the bug it replaces, because the reply
|
|
108
|
+
still sounds complete.
|
|
109
|
+
|
|
110
|
+
Suite 3022 / 215, tsc 0. Mutation-verified: reverting to two segments, and
|
|
111
|
+
dropping the legacy url/tailUrl, each fail the route contract test.
|
|
112
|
+
|
|
1
113
|
## 6.36.24
|
|
2
114
|
|
|
3
115
|
**Playback stopped after about a minute, whatever the reply length.**
|
package/package.json
CHANGED
package/server/lib/tts-cache.ts
CHANGED
|
@@ -101,17 +101,52 @@ const MAX_TOTAL_BYTES = 100 * 1024 * 1024 // 100 MB — in-memory cap
|
|
|
101
101
|
* practical exposure window is unchanged". That was true, and it is also what
|
|
102
102
|
* left the ceiling in place.
|
|
103
103
|
*/
|
|
104
|
-
const SESSION_IDLE_MS =
|
|
104
|
+
export const SESSION_IDLE_MS = (() => {
|
|
105
|
+
// DERIVED, like the ceiling below, and for the same reason: a window that does
|
|
106
|
+
// not cover its own inputs is the bug this replaced.
|
|
107
|
+
//
|
|
108
|
+
// Every segment's session is minted at /prepare. The client warms segment i+1
|
|
109
|
+
// at the START of segment i, so the gap between that touch and the real request
|
|
110
|
+
// is ONE FULL SEGMENT of playback. The window has to outlast that gap at the
|
|
111
|
+
// slowest speed the client offers:
|
|
112
|
+
//
|
|
113
|
+
// LATER_CHUNK_CHARS 900 chars
|
|
114
|
+
// speech rate ~19 chars/sec (measured)
|
|
115
|
+
// MIN_SPEED 0.5x (voice-output.ts clamps here)
|
|
116
|
+
// => 900 / 19 / 0.5 = 94.7s
|
|
117
|
+
//
|
|
118
|
+
// 60s covered 1x (47.4s) and 1.25x (37.9s) but NOT 0.75x (63.2s) -- a shipped
|
|
119
|
+
// option in the Settings picker. At 0.75x every other segment would 404, and
|
|
120
|
+
// because onError resolves rather than rejects, playback would skip on and
|
|
121
|
+
// sound complete while dropping half the reply. 120s covers 0.5x with margin.
|
|
122
|
+
return 120_000
|
|
123
|
+
})()
|
|
105
124
|
|
|
106
125
|
/**
|
|
107
126
|
* Absolute ceiling, never refreshed.
|
|
108
127
|
*
|
|
109
128
|
* The session UUID IS the auth for an unauthenticated play route, so a purely
|
|
110
|
-
* sliding window could be kept alive indefinitely by polling.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
129
|
+
* sliding window could be kept alive indefinitely by polling. This bounds the
|
|
130
|
+
* exposure of a leaked URL.
|
|
131
|
+
*
|
|
132
|
+
* DERIVED, not picked. Every segment of a reply is minted at prepare time, so
|
|
133
|
+
* the ceiling has to outlast the WHOLE reply played at the SLOWEST speed the
|
|
134
|
+
* client offers -- otherwise the last segments expire before playback reaches
|
|
135
|
+
* them, which is the same class of bug as the 60s deadline this replaced:
|
|
136
|
+
*
|
|
137
|
+
* MAX_LOCAL_TTS_CHARS 40,000 chars
|
|
138
|
+
* speech rate ~19 chars per second (measured)
|
|
139
|
+
* MIN_SPEED 0.5x (voice-output.ts clamps here)
|
|
140
|
+
* => 40000 / 19 / 0.5 = 70.2 minutes of audio
|
|
141
|
+
*
|
|
142
|
+
* 30 minutes was shorter than both that and the 1x case (35.1 min), so a maximal
|
|
143
|
+
* reply would have cut off near the end. 90 minutes covers the worst case with
|
|
144
|
+
* margin and is still a bounded window.
|
|
145
|
+
*
|
|
146
|
+
* If MAX_LOCAL_TTS_CHARS or MIN_SPEED changes, re-derive this. A ceiling that
|
|
147
|
+
* silently stops covering its own inputs is exactly what went wrong before.
|
|
113
148
|
*/
|
|
114
|
-
const SESSION_MAX_LIFETIME_MS =
|
|
149
|
+
export const SESSION_MAX_LIFETIME_MS = 90 * 60_000
|
|
115
150
|
|
|
116
151
|
/** Disk cache configuration (env-overridable). Defaults sized for "I run this
|
|
117
152
|
* on my laptop and forget about it for months" rather than a service tier.
|
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
|
@@ -133,101 +133,113 @@ function stripMarkdownLight(text: string): string {
|
|
|
133
133
|
.replace(/^[-*+]\s/gm, '- ')
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// to start playing audio in ~1-2s instead of the 8-15s a full-message OpenAI
|
|
140
|
-
// render takes for long replies. We do that by splitting the input into a
|
|
141
|
-
// short prefix the client can play immediately, and a tail that gets
|
|
142
|
-
// generated in parallel and chained on prefix `ended`.
|
|
143
|
-
//
|
|
144
|
-
// Heuristic-only — no NLP dependency. Markdown is already stripped above.
|
|
145
|
-
// Boundary detection uses the same .!? + whitespace rule as trimToCap so the
|
|
146
|
-
// two stay consistent. Bounded lengths protect against pathological inputs:
|
|
147
|
-
// - MIN_PREFIX_CHARS: short greetings ("Hi.") get padded with the next
|
|
148
|
-
// sentence so the prefix is long enough to mask tail-render latency.
|
|
149
|
-
// - MAX_PREFIX_CHARS: a single long sentence ("So basically I think we…
|
|
150
|
-
// spanning 600 chars") gets cut at a word boundary instead of running on.
|
|
151
|
-
const MIN_PREFIX_CHARS = 60
|
|
152
|
-
const MAX_PREFIX_CHARS = 250
|
|
153
|
-
|
|
154
|
-
/** Split `text` into a fast-playable prefix + a tail.
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Chunk sizes, in characters.
|
|
155
139
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
140
|
+
* MEASURED on this machine: Kokoro renders ~1.9ms per character, and speech runs
|
|
141
|
+
* ~19 characters per second of audio. Those two numbers are what make chunking a
|
|
142
|
+
* CORRECTNESS fix rather than a latency tweak.
|
|
143
|
+
*
|
|
144
|
+
* The two-segment prefix/tail split failed like this, measured on device
|
|
145
|
+
* 2026-08-23 with a 6,781-character reply:
|
|
146
|
+
*
|
|
147
|
+
* prefix generated in 0.5s (250 chars)
|
|
148
|
+
* tail generated in 12.4s (6,531 chars) -- AFTER the prefix, because the
|
|
149
|
+
* sidecar serializes synthesis behind one lock
|
|
150
|
+
* tail therefore ready ~12.9s after prepare
|
|
151
|
+
* prefix audio 15s ... but 12.0s at the user's 1.25x playback speed
|
|
152
|
+
*
|
|
153
|
+
* The phone asked for the tail at 12.0s. It existed at 12.9s. `/play` blocks
|
|
154
|
+
* until synthesis finishes before sending any headers -- 11.3s to first byte,
|
|
155
|
+
* measured -- and iOS's media loader will not wait: it buffers nothing and
|
|
156
|
+
* rejects with NotSupportedError.
|
|
157
|
+
*
|
|
158
|
+
* A margin that depends on reply length, voice, playback speed and machine load
|
|
159
|
+
* is not a margin. Small chunks remove the race entirely: by the time chunk 1
|
|
160
|
+
* finishes playing, every later chunk is long since rendered.
|
|
161
|
+
*
|
|
162
|
+
* FIRST_CHUNK stays small so first audio is still fast (~0.5s render, ~13s of
|
|
163
|
+
* speech). Later chunks are larger to keep the request count down; at ~1.7s
|
|
164
|
+
* render each they are always many seconds ahead of playback.
|
|
165
|
+
*/
|
|
166
|
+
export const FIRST_CHUNK_CHARS = 250
|
|
167
|
+
export const LATER_CHUNK_CHARS = 900
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Hard ceiling on segments for one reply.
|
|
171
|
+
*
|
|
172
|
+
* DERIVED, and the first version was wrong: it claimed 40 chunks covered
|
|
173
|
+
* "~36,000 characters, comfortably past MAX_LOCAL_TTS_CHARS". The real coverage
|
|
174
|
+
* is 250 + 39x900 = 35,350 -- 4,650 SHORT of the 40,000 cap, not past it. A
|
|
175
|
+
* maximal reply therefore produced a 5,574-char final segment, and on the OpenAI
|
|
176
|
+
* backend `trimToCap` is applied PER SEGMENT, so 1,574 characters were silently
|
|
177
|
+
* dropped -- contradicting this chunker's own "nothing is dropped" contract two
|
|
178
|
+
* definitions above.
|
|
179
|
+
*
|
|
180
|
+
* ceil((MAX_LOCAL_TTS_CHARS - FIRST_CHUNK_CHARS) / LATER_CHUNK_CHARS) + 1
|
|
181
|
+
* = ceil(39750 / 900) + 1 = 45 + 1 = 46 (250 + 45x900 = 40,750)
|
|
182
|
+
*
|
|
183
|
+
* 45 was the first answer here and was itself 150 chars short -- which is why
|
|
184
|
+
* the test below computes the coverage instead of trusting this arithmetic.
|
|
185
|
+
*
|
|
186
|
+
* Re-derive if either cap moves; the test below fails if this stops covering.
|
|
187
|
+
*/
|
|
188
|
+
export const MAX_CHUNKS = 46
|
|
180
189
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Split `text` into sequentially-playable chunks.
|
|
192
|
+
*
|
|
193
|
+
* Contract:
|
|
194
|
+
* - Always returns at least one non-empty chunk for non-empty input.
|
|
195
|
+
* - Concatenating the chunks reproduces the input's words in order. Nothing is
|
|
196
|
+
* dropped -- a chunker that loses text is worse than the bug it replaces.
|
|
197
|
+
* - Breaks at sentence terminators where possible, at word boundaries
|
|
198
|
+
* otherwise, and mid-word only for a single unbroken run longer than a chunk.
|
|
199
|
+
* - The first chunk is small (fast first audio); the rest are larger.
|
|
200
|
+
*/
|
|
201
|
+
export function splitForChunks(text: string): string[] {
|
|
202
|
+
const trimmed = text.trim()
|
|
203
|
+
if (trimmed.length === 0) return []
|
|
204
|
+
|
|
205
|
+
const chunks: string[] = []
|
|
206
|
+
let rest = trimmed
|
|
207
|
+
while (rest.length > 0 && chunks.length < MAX_CHUNKS) {
|
|
208
|
+
const cap = chunks.length === 0 ? FIRST_CHUNK_CHARS : LATER_CHUNK_CHARS
|
|
209
|
+
// `rest` MUST be cleared before breaking. Leaving it set made the overflow
|
|
210
|
+
// guard below append the final piece a second time -- `splitForChunks('Hi.')`
|
|
211
|
+
// returned ['Hi. Hi.']. Caught by the never-lose-a-word test, which turned
|
|
212
|
+
// out to catch duplication just as well as loss.
|
|
213
|
+
if (rest.length <= cap) { chunks.push(rest); rest = ''; break }
|
|
214
|
+
|
|
215
|
+
const window = rest.slice(0, cap)
|
|
216
|
+
// Prefer a sentence end. NOTE: this is `/[.!?]\s+/` while `trimToCap` matches
|
|
217
|
+
// a literal '. ' -- they disagree on '.\n' and on double spaces. Not unified
|
|
218
|
+
// here because trimToCap only ever trims a hard cap, but do not describe them
|
|
219
|
+
// as the same rule. (An earlier comment claimed all three agreed, naming a
|
|
220
|
+
// prefix splitter deleted in the same commit.)
|
|
221
|
+
let cut = -1
|
|
222
|
+
const re = /[.!?]\s+/g
|
|
223
|
+
let m: RegExpExecArray | null
|
|
224
|
+
while ((m = re.exec(window)) !== null) cut = m.index + m[0].length
|
|
225
|
+
// Only accept a sentence break that is not absurdly early, or a 3-word
|
|
226
|
+
// chunk followed by a 900-char one reads as a stutter.
|
|
227
|
+
if (cut < cap * 0.4) {
|
|
228
|
+
const space = window.lastIndexOf(' ')
|
|
229
|
+
cut = space > 0 ? space + 1 : cap
|
|
191
230
|
}
|
|
231
|
+
chunks.push(rest.slice(0, cut).trim())
|
|
232
|
+
rest = rest.slice(cut).trim()
|
|
192
233
|
}
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
let chosenCut = sentenceBoundaries[sentenceBoundaries.length - 1]
|
|
198
|
-
for (let i = 0; i < sentenceBoundaries.length; i++) {
|
|
199
|
-
const cut = sentenceBoundaries[i]
|
|
200
|
-
const sentencesCovered = i + 1
|
|
201
|
-
const longEnough = cut >= MIN_PREFIX_CHARS
|
|
202
|
-
const tooLong = cut > MAX_PREFIX_CHARS
|
|
203
|
-
const hasTwo = sentencesCovered >= 2
|
|
204
|
-
if (tooLong) {
|
|
205
|
-
// Previous boundary (if any) was the best fit; if this is the first
|
|
206
|
-
// boundary AND it already overshoots MAX, fall back to a word-boundary
|
|
207
|
-
// cut inside the first sentence so the prefix doesn't blow past the cap.
|
|
208
|
-
if (i === 0) {
|
|
209
|
-
const slice = trimmed.slice(0, MAX_PREFIX_CHARS)
|
|
210
|
-
const lastSpace = slice.lastIndexOf(' ')
|
|
211
|
-
const cutAt = lastSpace > MIN_PREFIX_CHARS ? lastSpace : MAX_PREFIX_CHARS
|
|
212
|
-
chosenCut = cutAt
|
|
213
|
-
} else {
|
|
214
|
-
chosenCut = sentenceBoundaries[i - 1]
|
|
215
|
-
}
|
|
216
|
-
break
|
|
217
|
-
}
|
|
218
|
-
if (longEnough && hasTwo) {
|
|
219
|
-
chosenCut = cut
|
|
220
|
-
break
|
|
221
|
-
}
|
|
222
|
-
chosenCut = cut
|
|
234
|
+
// MAX_CHUNKS reached with text left: append it to the last chunk rather than
|
|
235
|
+
// dropping it. A long final chunk is a latency problem; dropped text is a lie.
|
|
236
|
+
if (rest.length > 0 && chunks.length > 0) {
|
|
237
|
+
chunks[chunks.length - 1] = `${chunks[chunks.length - 1]} ${rest}`.trim()
|
|
223
238
|
}
|
|
224
|
-
|
|
225
|
-
const prefix = trimmed.slice(0, chosenCut).trim()
|
|
226
|
-
const tail = trimmed.slice(chosenCut).trim()
|
|
227
|
-
if (tail.length === 0) return { prefix: trimmed, tail: '' }
|
|
228
|
-
return { prefix, tail }
|
|
239
|
+
return chunks.filter((c) => c.length > 0)
|
|
229
240
|
}
|
|
230
241
|
|
|
242
|
+
|
|
231
243
|
function openaiBudgetOk(): boolean {
|
|
232
244
|
try {
|
|
233
245
|
assertOpenAITtsBudget()
|
|
@@ -442,6 +454,9 @@ async function generateLocalIntoCache(
|
|
|
442
454
|
voice: string,
|
|
443
455
|
format: string,
|
|
444
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,
|
|
445
460
|
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
446
461
|
// A memory/latency bound, NOT OpenAI's 4096. Kokoro reads what it is handed;
|
|
447
462
|
// the old shared cap truncated local speech at ~3-4 pages for no reason that
|
|
@@ -459,7 +474,7 @@ async function generateLocalIntoCache(
|
|
|
459
474
|
}
|
|
460
475
|
try {
|
|
461
476
|
// Local path ignores COS_VOICE_INSTRUCTIONS / per-request instructions.
|
|
462
|
-
const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal })
|
|
477
|
+
const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal, priority })
|
|
463
478
|
if (!bytes.length) {
|
|
464
479
|
abortEntry(hash)
|
|
465
480
|
return { ok: false, status: 502, message: 'local TTS returned empty body' }
|
|
@@ -484,10 +499,11 @@ async function generateIntoCache(
|
|
|
484
499
|
format: string,
|
|
485
500
|
instructions: string,
|
|
486
501
|
signal?: AbortSignal,
|
|
502
|
+
priority = false,
|
|
487
503
|
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
488
504
|
if (getCached(hash)) return { ok: true }
|
|
489
505
|
if (decision.backend === 'local') {
|
|
490
|
-
return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal)
|
|
506
|
+
return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal, priority)
|
|
491
507
|
}
|
|
492
508
|
return generateOpenAIIntoCache(
|
|
493
509
|
hash,
|
|
@@ -508,6 +524,9 @@ async function generateWithFallback(opts: {
|
|
|
508
524
|
enginePreference?: TtsEnginePreference | null
|
|
509
525
|
signal?: AbortSignal
|
|
510
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
|
|
511
530
|
}): Promise<{ ok: true; hash: string } | { ok: false; status: number; message: string }> {
|
|
512
531
|
const enginePreference = opts.enginePreference ?? null
|
|
513
532
|
const preferOpenAI = enginePreference === 'openai'
|
|
@@ -541,6 +560,7 @@ async function generateWithFallback(opts: {
|
|
|
541
560
|
opts.format,
|
|
542
561
|
opts.instructions,
|
|
543
562
|
opts.signal,
|
|
563
|
+
opts.priority === true,
|
|
544
564
|
)
|
|
545
565
|
if (primary.ok) {
|
|
546
566
|
if (softEscapedToOpenAI) {
|
|
@@ -574,6 +594,7 @@ async function generateWithFallback(opts: {
|
|
|
574
594
|
opts.format,
|
|
575
595
|
opts.instructions,
|
|
576
596
|
opts.signal,
|
|
597
|
+
opts.priority === true,
|
|
577
598
|
)
|
|
578
599
|
if (localResult.ok) {
|
|
579
600
|
if (opts.sessionId) rebindSessionHash(opts.sessionId, localHash)
|
|
@@ -604,6 +625,7 @@ async function generateWithFallback(opts: {
|
|
|
604
625
|
opts.format,
|
|
605
626
|
opts.instructions,
|
|
606
627
|
opts.signal,
|
|
628
|
+
opts.priority === true,
|
|
607
629
|
)
|
|
608
630
|
if (openaiResult.ok) {
|
|
609
631
|
announceKokoroFallbackToOpenAI(failReason)
|
|
@@ -791,6 +813,7 @@ ttsRouter.post('/tts/stream', async (req, res) => {
|
|
|
791
813
|
voice: decision.backendVoice,
|
|
792
814
|
format: requestedFormat,
|
|
793
815
|
signal: upstreamController.signal,
|
|
816
|
+
priority: true,
|
|
794
817
|
})
|
|
795
818
|
if (upstreamController.signal.aborted) return
|
|
796
819
|
res.writeHead(200, {
|
|
@@ -936,15 +959,30 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
|
|
|
936
959
|
return res.json({ url: `/api/tts/play/${uuid}`, ...engineMeta })
|
|
937
960
|
}
|
|
938
961
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
962
|
+
// N SEGMENTS, not two. See splitForChunks for why the prefix/tail pair was a
|
|
963
|
+
// race the user lost by less than a second.
|
|
964
|
+
//
|
|
965
|
+
// Warm order matters and is already correct: the sidecar serializes synthesis
|
|
966
|
+
// behind one lock, so minting in order means chunk 1 renders first and every
|
|
967
|
+
// later chunk finishes long before playback reaches it.
|
|
968
|
+
const chunks = splitForChunks(capped)
|
|
969
|
+
if (chunks.length === 0) {
|
|
970
|
+
return res.status(400).json({ error: 'text is required (non-empty string)' })
|
|
971
|
+
}
|
|
972
|
+
const mints = chunks.map((chunk) => mintAndWarm(chunk))
|
|
973
|
+
const urls = mints.map((m) => `/api/tts/play/${m.uuid}`)
|
|
974
|
+
|
|
975
|
+
if (urls.length === 1) {
|
|
976
|
+
return res.json({ url: urls[0], urls, chunks: 1, ...engineMeta })
|
|
943
977
|
}
|
|
944
|
-
const tailMint = mintAndWarm(tail)
|
|
945
978
|
res.json({
|
|
946
|
-
url
|
|
947
|
-
|
|
979
|
+
// `urls` is the real contract. `url` and `tailUrl` are kept so a client
|
|
980
|
+
// older than 6.8.428 still plays the first two segments instead of
|
|
981
|
+
// nothing -- degraded, but not broken, which is the point of keeping them.
|
|
982
|
+
url: urls[0],
|
|
983
|
+
tailUrl: urls[1],
|
|
984
|
+
urls,
|
|
985
|
+
chunks: urls.length,
|
|
948
986
|
...engineMeta,
|
|
949
987
|
})
|
|
950
988
|
} catch (err) {
|
|
@@ -1085,6 +1123,8 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
|
|
|
1085
1123
|
enginePreference,
|
|
1086
1124
|
signal: upstreamController.signal,
|
|
1087
1125
|
sessionId: req.params.session,
|
|
1126
|
+
// /play cold miss: the listener is waiting on this render right now.
|
|
1127
|
+
priority: true,
|
|
1088
1128
|
})
|
|
1089
1129
|
|
|
1090
1130
|
if (!result.ok) {
|