@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.24",
3
+ "version": "6.36.27",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = 60_000
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. Thirty minutes is
111
- * far longer than any plausible single reply (211 seconds for 4,000 characters)
112
- * and still bounds the exposure of a leaked URL.
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 = 30 * 60_000
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.
@@ -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
- const timeoutMs = Number.isFinite(LOCAL_TTS_SYNTH_TIMEOUT_MS) && LOCAL_TTS_SYNTH_TIMEOUT_MS > 0
400
- ? LOCAL_TTS_SYNTH_TIMEOUT_MS
401
- : 12_000
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'
@@ -133,101 +133,113 @@ function stripMarkdownLight(text: string): string {
133
133
  .replace(/^[-*+]\s/gm, '- ')
134
134
  }
135
135
 
136
- // ── v5.9.6 fast-prefix splitter ───────────────────────────────────────────
137
- //
138
- // The "fast first-audio" path (POST /api/tts/prepare with fast: true) wants
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
- * Contract:
157
- * - Returns `{ prefix, tail }` with `prefix` non-empty and `tail` either ''
158
- * (the message fits in one chunk and the route should fall back to v5.9.5
159
- * single-URL behavior) or the remainder.
160
- * - Prefix targets the first ~2 sentences but expands if either is short
161
- * (to clear MIN_PREFIX_CHARS) and contracts if a single sentence exceeds
162
- * MAX_PREFIX_CHARS (cut at the last word boundary inside the cap).
163
- * - Caller is responsible for trimToCap'ping the input first. */
164
- export function splitForFastPrefix(text: string): { prefix: string; tail: string } {
165
- const trimmed = text.trim()
166
- if (trimmed.length === 0) return { prefix: '', tail: '' }
167
- // Short enough to play as a single chunk no benefit from splitting.
168
- if (trimmed.length <= MIN_PREFIX_CHARS) return { prefix: trimmed, tail: '' }
169
-
170
- // Walk sentence terminators forward, accumulating sentences until we cover
171
- // at least MIN_PREFIX_CHARS. Up to 2 sentences if both are reasonably sized,
172
- // more if the first ones are tiny. Indices point to the boundary AFTER the
173
- // terminator + whitespace (the start of the next sentence).
174
- const sentenceBoundaries: number[] = []
175
- const re = /[.!?]\s+/g
176
- let m: RegExpExecArray | null
177
- while ((m = re.exec(trimmed)) !== null) {
178
- sentenceBoundaries.push(m.index + m[0].length)
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
- if (sentenceBoundaries.length === 0) {
182
- // No sentence terminators (one giant run-on). Fall back to a word-boundary
183
- // cut at MAX_PREFIX_CHARS. If the whole thing fits in MAX, it's a single chunk.
184
- if (trimmed.length <= MAX_PREFIX_CHARS) return { prefix: trimmed, tail: '' }
185
- const slice = trimmed.slice(0, MAX_PREFIX_CHARS)
186
- const lastSpace = slice.lastIndexOf(' ')
187
- const cut = lastSpace > MIN_PREFIX_CHARS ? lastSpace : MAX_PREFIX_CHARS
188
- return {
189
- prefix: trimmed.slice(0, cut).trim(),
190
- tail: trimmed.slice(cut).trim(),
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
- // Pick the smallest cut that satisfies (length >= MIN_PREFIX_CHARS) AND
195
- // covers >= 2 sentences when possible. Stop early once a candidate also
196
- // exceeds MAX_PREFIX_CHARS the previous candidate is the best fit.
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
- const { prefix, tail } = splitForFastPrefix(capped)
940
- const prefixMint = mintAndWarm(prefix)
941
- if (tail.length === 0) {
942
- return res.json({ url: `/api/tts/play/${prefixMint.uuid}`, ...engineMeta })
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: `/api/tts/play/${prefixMint.uuid}`,
947
- tailUrl: `/api/tts/play/${tailMint.uuid}`,
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) {