@gotcos/glasses-server 6.36.26 → 6.36.28
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 +95 -0
- package/package.json +1 -1
- package/server/index.ts +6 -0
- package/server/lib/tts-cache.ts +151 -35
- package/server/lib/tts-local.ts +164 -3
- package/server/routes/tts.ts +32 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
+
## 6.36.28
|
|
2
|
+
|
|
3
|
+
THE bug. Three earlier fixes tonight were each real and none was this one.
|
|
4
|
+
|
|
5
|
+
Every segment of a reply is minted at /prepare with a 120s idle deadline that
|
|
6
|
+
starts AT MINT. But the client only touches segment k when segment k-1 starts
|
|
7
|
+
playing. Measured on device, 6,781 chars at 1.25x:
|
|
8
|
+
|
|
9
|
+
seg 4 first touched t+101s played
|
|
10
|
+
seg 5 first touched t+147s FAILED
|
|
11
|
+
seg 6 first touched t+215s FAILED
|
|
12
|
+
seg 7 never reached FAILED
|
|
13
|
+
|
|
14
|
+
Segment 5 was deleted 27s before the client first asked for it. Reproduced with
|
|
15
|
+
curl against the running server, replaying the exact timeline: HTTP 404 at
|
|
16
|
+
segment 5, t+216s. The 404 reaches the audio element as NotSupportedError at
|
|
17
|
+
readyState 0 -- identical to every other media failure, which is why a char cap,
|
|
18
|
+
a wider idle window and a render gate all left playback stopping at segment 5.
|
|
19
|
+
|
|
20
|
+
WHAT CHANGED
|
|
21
|
+
|
|
22
|
+
- An unread session gets a grace window DERIVED from how long the whole reply
|
|
23
|
+
takes to speak, plus one segment of margin (the last segment is first touched
|
|
24
|
+
when the second-to-last starts playing).
|
|
25
|
+
- A read may only ever EXTEND a deadline, never shorten one. This is not
|
|
26
|
+
cosmetic: the client warms segment k one whole segment before playing it, so
|
|
27
|
+
collapsing the grace on that first read SPENT it. At 0.5x a 900-char segment
|
|
28
|
+
is 170s of wall time against a 120s window, and the reply lost segment 1 while
|
|
29
|
+
holding a 1,476s grace. Found by QA, after the first version of this fix.
|
|
30
|
+
- SESSION_IDLE_MS and SESSION_MAX_LIFETIME_MS are now COMPUTED from shared
|
|
31
|
+
constants rather than written down. Both were derived from ~19 chars/sec, the
|
|
32
|
+
FAST voice, and this release is the one that measured the slow voice at 10.
|
|
33
|
+
Leaving them meant the file asserted two contradictory worst cases:
|
|
34
|
+
idle 900 / 10 / 0.5 = 180s against a 120s window
|
|
35
|
+
ceiling 40,000 / 10 / 0.5 = 133 min against a 90 min ceiling
|
|
36
|
+
The ceiling had also started silently truncating the grace for any reply over
|
|
37
|
+
~26,400 chars, relocating the same failure to roughly segment 31 of 46.
|
|
38
|
+
- MAX_LOCAL_TTS_CHARS, LATER_CHUNK_CHARS, the speech rate and the minimum
|
|
39
|
+
playback rate now live in one place and are imported by the tests that used to
|
|
40
|
+
restate them. That duplication is why the contradiction above was invisible.
|
|
41
|
+
- /play now logs the 404 it produces. The server held this fact all evening and
|
|
42
|
+
recorded nothing, so the only reporter was a fire-and-forget client call.
|
|
43
|
+
- CORS exposes Content-Range and Accept-Ranges, without which the client's
|
|
44
|
+
failure probe reads them as null from the null-origin companion WebView.
|
|
45
|
+
|
|
46
|
+
SECURITY, stated plainly rather than buried
|
|
47
|
+
|
|
48
|
+
An unread capability now lives far longer than 120 seconds -- up to the ceiling,
|
|
49
|
+
which is 136 minutes. That is a real widening and it is the price of playing a
|
|
50
|
+
40,000-character reply, which genuinely takes over two hours at 0.5x. What is
|
|
51
|
+
unchanged: the ceiling is absolute, reading cannot extend a capability past the
|
|
52
|
+
grace it was minted with, and an unread capability still expires on its own.
|
|
53
|
+
Both properties are pinned and both mutate red.
|
|
54
|
+
|
|
55
|
+
216 files, 3047 tests in scope (218 / 3053 including a parallel session's two
|
|
56
|
+
files, which are not part of this release).
|
|
57
|
+
|
|
58
|
+
## 6.36.27
|
|
59
|
+
|
|
60
|
+
The sidecar renders one request at a time. The server finally acts like it.
|
|
61
|
+
|
|
62
|
+
WHAT BROKE. Chunking (6.36.25) split a reply into 9 segments and /prepare
|
|
63
|
+
pre-warmed all 9 at once. The synthesis timeout was armed when a request was
|
|
64
|
+
ISSUED, so it ran while that request sat in the sidecar's queue. Measured on a
|
|
65
|
+
6,781-character reply: renders took ~2.6s each, but segment 5 spent 11.5s of its
|
|
66
|
+
12,000ms budget waiting for a turn. On device it tipped over, the pre-warm
|
|
67
|
+
returned 502, and iOS surfaced it as NotSupportedError. Five of nine segments
|
|
68
|
+
played.
|
|
69
|
+
|
|
70
|
+
The 12,000ms constant was not wrong when it was written -- its own comment says
|
|
71
|
+
it exists to "bound hung sidecar so local_first can fall back before session TTL
|
|
72
|
+
(~60s)", from an era when a reply was ONE render. Chunking changed the input and
|
|
73
|
+
nothing re-derived the limit.
|
|
74
|
+
|
|
75
|
+
- New render gate in tts-local.ts. One render reaches the sidecar at a time, so
|
|
76
|
+
the synthesis timeout now bounds RENDER, which is what it always claimed to
|
|
77
|
+
bound. Queue wait is governed separately.
|
|
78
|
+
- Queue wait has its own DERIVED ceiling: one synthesis budget per render ahead,
|
|
79
|
+
plus one budget of headroom. The headroom is not slack -- without it the first
|
|
80
|
+
waiter's ceiling expires in a dead heat with the holder's own timeout, and a
|
|
81
|
+
request that was about to be served is rejected in the same tick. Found by the
|
|
82
|
+
test, not by reasoning.
|
|
83
|
+
- /play outranks /prepare pre-warm. A user is waiting on the first and nobody is
|
|
84
|
+
waiting on the second; without priority, segment 5's playback request queues
|
|
85
|
+
behind pre-warms for 6, 7 and 8 -- work not needed for minutes. Priority never
|
|
86
|
+
reorders playback against itself.
|
|
87
|
+
- /api/health tts_local now reports renderQueueDepth. This was not observable on
|
|
88
|
+
2026-08-23 and that cost an evening.
|
|
89
|
+
|
|
90
|
+
Every guard mutation-verified, including one that only counting could catch:
|
|
91
|
+
dropping the waiter's detach left all ten behaviour tests green while leaking an
|
|
92
|
+
abort listener per queued render (45 of them on a 46-segment reply).
|
|
93
|
+
|
|
94
|
+
217 files, 3042 tests.
|
|
95
|
+
|
|
1
96
|
## 6.36.26
|
|
2
97
|
|
|
3
98
|
Deadline hardening for the segmented TTS path shipped in 6.36.25. Three constants
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -172,6 +172,12 @@ app.use(cors({
|
|
|
172
172
|
if (isAllowedNetworkOrigin(origin)) return cb(null, true)
|
|
173
173
|
cb(new Error('CORS blocked'))
|
|
174
174
|
},
|
|
175
|
+
// Without this, a cross-origin reader sees only the CORS-safelisted response
|
|
176
|
+
// headers. The companion's origin is `null` (a file:// WebView), so it is
|
|
177
|
+
// cross-origin by definition, and the TTS failure probe would log
|
|
178
|
+
// contentRange/acceptRanges as null on every single call -- leading the next
|
|
179
|
+
// reader to conclude the server had stopped sending Range headers.
|
|
180
|
+
exposedHeaders: ['Content-Range', 'Accept-Ranges'],
|
|
175
181
|
}))
|
|
176
182
|
// Auth middleware — always active (token is auto-generated if not set).
|
|
177
183
|
// Mounted before body parsers so rejected uploads cannot consume parse memory.
|
package/server/lib/tts-cache.ts
CHANGED
|
@@ -101,25 +101,68 @@ 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
|
+
/**
|
|
105
|
+
* SHARED PHYSICAL CONSTANTS for every TTS deadline in this file.
|
|
106
|
+
*
|
|
107
|
+
* These sit at the top because three separate deadlines are derived from them,
|
|
108
|
+
* and on 2026-08-23 two of those deadlines were derived from a DIFFERENT
|
|
109
|
+
* speech rate than the third. Both cannot be right, and the tests could not see
|
|
110
|
+
* the contradiction because each restated its own copy of the rate.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/** The longest a single chunk can be. Mirrors the chunker in routes/tts.ts. */
|
|
114
|
+
export const LATER_CHUNK_CHARS = 900
|
|
115
|
+
|
|
116
|
+
/** The most text one reply can be asked to speak locally. */
|
|
117
|
+
export const MAX_LOCAL_TTS_CHARS = 40_000
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Characters spoken per second by the SLOWEST voice, not the average.
|
|
121
|
+
*
|
|
122
|
+
* Measured on device 2026-08-23: am_echo ~19 chars/sec, bm_george as low as
|
|
123
|
+
* 10.6 on one segment. Every deadline below uses 10, because a window sized on
|
|
124
|
+
* the fast voice does not cover the slow one -- which is the entire class of
|
|
125
|
+
* bug these constants exist to end.
|
|
126
|
+
*
|
|
127
|
+
* CAVEAT, stated because it is load-bearing: this is one segment of one voice
|
|
128
|
+
* out of 28 shipped Kokoro voices. It errs safe for a WINDOW (too slow means
|
|
129
|
+
* too generous) but it has not been censused, and a voice slower than 10 would
|
|
130
|
+
* undersize every deadline here at once.
|
|
131
|
+
*/
|
|
132
|
+
export const SLOWEST_SPEECH_CHARS_PER_SEC = 10
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The slowest rate playback can actually run at.
|
|
136
|
+
*
|
|
137
|
+
* NOT the slowest option the picker offers -- that is 0.75x. This is the clamp
|
|
138
|
+
* floor in the client's getPreferredSpeed(), which exists so a poisoned
|
|
139
|
+
* localStorage value cannot produce an absurd rate. Deadlines must survive the
|
|
140
|
+
* clamp, not just the menu.
|
|
141
|
+
*/
|
|
142
|
+
export const MIN_PLAYBACK_RATE = 0.5
|
|
143
|
+
|
|
144
|
+
/** Wall-clock ms to speak `chars` at the slowest voice and slowest rate. */
|
|
145
|
+
export function worstCaseSpeechMs(chars: number): number {
|
|
146
|
+
return (chars / SLOWEST_SPEECH_CHARS_PER_SEC / MIN_PLAYBACK_RATE) * 1000
|
|
147
|
+
}
|
|
148
|
+
|
|
104
149
|
export const SESSION_IDLE_MS = (() => {
|
|
105
|
-
//
|
|
106
|
-
//
|
|
150
|
+
// COMPUTED from the shared constants, not written down.
|
|
151
|
+
//
|
|
152
|
+
// A session that is being read must outlast the gap between two reads. The
|
|
153
|
+
// client warms segment i+1 at the START of segment i and does not touch it
|
|
154
|
+
// again until segment i FINISHES, so that gap is one full segment:
|
|
107
155
|
//
|
|
108
|
-
//
|
|
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:
|
|
156
|
+
// worstCaseSpeechMs(LATER_CHUNK_CHARS) = 900 / 10 / 0.5 = 180s
|
|
112
157
|
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
158
|
+
// The previous value, 120s, was derived from ~19 chars/sec -- the FAST voice.
|
|
159
|
+
// Once bm_george was measured at 10.6 the derivation was stale, and 900 chars
|
|
160
|
+
// at 0.5x is 180s against a 120s window. That is the same defect this file
|
|
161
|
+
// has now hit three times, so the value is computed here rather than chosen.
|
|
117
162
|
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
// sound complete while dropping half the reply. 120s covers 0.5x with margin.
|
|
122
|
-
return 120_000
|
|
163
|
+
// x1.5 of margin absorbs a stalled segment or a slow refill without letting an
|
|
164
|
+
// abandoned capability linger: 4.5 minutes, not 90.
|
|
165
|
+
return Math.ceil(worstCaseSpeechMs(LATER_CHUNK_CHARS) * 1.5)
|
|
123
166
|
})()
|
|
124
167
|
|
|
125
168
|
/**
|
|
@@ -129,24 +172,32 @@ export const SESSION_IDLE_MS = (() => {
|
|
|
129
172
|
* sliding window could be kept alive indefinitely by polling. This bounds the
|
|
130
173
|
* exposure of a leaked URL.
|
|
131
174
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
175
|
+
* COMPUTED from the largest reply the system can be asked to speak, at the
|
|
176
|
+
* slowest voice and slowest rate, plus one segment of margin -- which is exactly
|
|
177
|
+
* what initialGraceMs computes, so the ceiling is defined as "the largest grace
|
|
178
|
+
* that can legally be issued":
|
|
136
179
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* MIN_SPEED 0.5x (voice-output.ts clamps here)
|
|
140
|
-
* => 40000 / 19 / 0.5 = 70.2 minutes of audio
|
|
180
|
+
* worstCaseSpeechMs(40,000) + worstCaseSpeechMs(900)
|
|
181
|
+
* = 40000/10/0.5 + 900/10/0.5 = 8000s + 180s = 136.3 minutes
|
|
141
182
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
183
|
+
* The previous 90 minutes was derived from ~19 chars/sec. After the slow voice
|
|
184
|
+
* was measured at 10.6 that stopped covering its own input: initialGraceMs
|
|
185
|
+
* exceeded the ceiling for any reply over ~26,400 characters, so the ceiling
|
|
186
|
+
* silently truncated the grace and a maximal reply's last segments died before
|
|
187
|
+
* playback reached them -- the segment-5 failure relocated to segment 31 of 46.
|
|
145
188
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
189
|
+
* This is a long-lived bearer capability and that is a real trade, stated rather
|
|
190
|
+
* than buried: a 40,000-character reply genuinely takes over two hours to speak
|
|
191
|
+
* at 0.5x, and the capability must outlive the audio it serves. The bound is
|
|
192
|
+
* still absolute and still unextendable by reading.
|
|
193
|
+
*
|
|
194
|
+
* There is no separate arithmetic to keep in sync. Change a shared constant and
|
|
195
|
+
* both this and the grace move together, and the test below asserts the ceiling
|
|
196
|
+
* covers the largest grace.
|
|
148
197
|
*/
|
|
149
|
-
export const SESSION_MAX_LIFETIME_MS =
|
|
198
|
+
export const SESSION_MAX_LIFETIME_MS = Math.ceil(
|
|
199
|
+
worstCaseSpeechMs(MAX_LOCAL_TTS_CHARS) + worstCaseSpeechMs(LATER_CHUNK_CHARS),
|
|
200
|
+
)
|
|
150
201
|
|
|
151
202
|
/** Disk cache configuration (env-overridable). Defaults sized for "I run this
|
|
152
203
|
* on my laptop and forget about it for months" rather than a service tier.
|
|
@@ -562,12 +613,60 @@ function sweepStaleByAge(): void {
|
|
|
562
613
|
* (hash, text, voice, format) bundle. The play route may reread it for native
|
|
563
614
|
* Range refills during the 60-second TTL; expired sessions are rejected and
|
|
564
615
|
* reaped by the periodic sweeper below. */
|
|
565
|
-
|
|
616
|
+
/**
|
|
617
|
+
* How long a session that has NEVER been read stays alive.
|
|
618
|
+
*
|
|
619
|
+
* WHY THIS IS NOT SESSION_IDLE_MS. Every segment of a reply is minted at
|
|
620
|
+
* /prepare, but the client only touches segment k when segment k-1 STARTS
|
|
621
|
+
* playing. For a 9-segment reply that first touch can be minutes away:
|
|
622
|
+
*
|
|
623
|
+
* measured on device, 6,781 chars at 1.25x
|
|
624
|
+
* seg 4 first touched at t+101s played
|
|
625
|
+
* seg 5 first touched at t+147s FAILED
|
|
626
|
+
* seg 6 first touched at t+215s FAILED
|
|
627
|
+
* seg 7 never reached FAILED
|
|
628
|
+
*
|
|
629
|
+
* With a flat 120s idle deadline running from MINT, segment 5 was already dead
|
|
630
|
+
* when the client first asked for it, and the 404 arrived as
|
|
631
|
+
* NotSupportedError. Playback stopped at exactly segment 5 on every run.
|
|
632
|
+
*
|
|
633
|
+
* So the idle clock must not start before anyone could reasonably read it. An
|
|
634
|
+
* unread session gets a grace window derived from how long the WHOLE reply takes
|
|
635
|
+
* to speak at the slowest voice and slowest rate; the 120s idle window applies
|
|
636
|
+
* from the first read onward, when it means what it says.
|
|
637
|
+
*/
|
|
638
|
+
export function initialGraceMs(totalChars: number): number {
|
|
639
|
+
// The margin is ONE SEGMENT, not one idle window. The last segment is first
|
|
640
|
+
// touched when the second-to-last STARTS playing, so the window has to reach
|
|
641
|
+
// one segment past the end of the reply. An earlier version added
|
|
642
|
+
// SESSION_IDLE_MS here and described it as covering that gap -- which it does
|
|
643
|
+
// not, since a segment can be 180s at this file's own slowest rate.
|
|
644
|
+
const lastSegmentMs = worstCaseSpeechMs(LATER_CHUNK_CHARS)
|
|
645
|
+
return Math.max(SESSION_IDLE_MS, worstCaseSpeechMs(totalChars) + lastSegmentMs)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function createSession(
|
|
649
|
+
s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>,
|
|
650
|
+
opts: { graceMs?: number } = {},
|
|
651
|
+
): string {
|
|
566
652
|
const uuid = randomUUID()
|
|
567
653
|
const now = Date.now()
|
|
654
|
+
// Number.isFinite, not just ??. Math.max(120000, NaN) is NaN, and NaN fails
|
|
655
|
+
// every comparison in peekSession, so a NaN grace would leave the session
|
|
656
|
+
// governed only by the ceiling. Cheap to close, silent if left open.
|
|
657
|
+
const requested = opts.graceMs
|
|
658
|
+
const grace = Number.isFinite(requested)
|
|
659
|
+
? Math.max(SESSION_IDLE_MS, requested as number)
|
|
660
|
+
: SESSION_IDLE_MS
|
|
568
661
|
sessions.set(uuid, {
|
|
569
662
|
...s,
|
|
570
|
-
|
|
663
|
+
// Belt and braces, NOT the thing that enforces the ceiling. Mutation shows
|
|
664
|
+
// removing this Math.min changes nothing observable: peekSession and
|
|
665
|
+
// reapExpiredSessions both test hardExpiresAt independently, so a grace
|
|
666
|
+
// beyond the ceiling is already unreachable. Kept because a stored deadline
|
|
667
|
+
// that lies about its own limit invites a future reader to trust it -- but
|
|
668
|
+
// do not mistake it for the guard.
|
|
669
|
+
expiresAt: Math.min(now + grace, now + SESSION_MAX_LIFETIME_MS),
|
|
571
670
|
hardExpiresAt: now + SESSION_MAX_LIFETIME_MS,
|
|
572
671
|
})
|
|
573
672
|
return uuid
|
|
@@ -594,10 +693,27 @@ export function peekSession(uuid: string): SessionEntry | null {
|
|
|
594
693
|
sessions.delete(uuid)
|
|
595
694
|
return null
|
|
596
695
|
}
|
|
597
|
-
// SLIDING
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
|
|
696
|
+
// SLIDING, and it may only ever EXTEND a deadline -- never shorten one.
|
|
697
|
+
//
|
|
698
|
+
// The Math.max is the whole point, and its absence was a defect that survived
|
|
699
|
+
// into review. `warmNext(i)` reads segment i+1 at the START of segment i, and
|
|
700
|
+
// then nothing touches it again until segment i FINISHES, one full segment
|
|
701
|
+
// later. If that first read collapsed the derived grace to SESSION_IDLE_MS,
|
|
702
|
+
// the warm would SPEND the grace instead of using it, and any segment whose
|
|
703
|
+
// playback exceeds 120s would expire before its turn:
|
|
704
|
+
//
|
|
705
|
+
// 900 chars at 10.6 chars/sec = 85s of audio
|
|
706
|
+
// at 0.5x = 170s of wall time > 120s
|
|
707
|
+
//
|
|
708
|
+
// Verified by replaying the real warm pattern: with a plain assignment the
|
|
709
|
+
// 6,781-char reply lost segment 1 at t+170s while holding a 1,476s grace.
|
|
710
|
+
//
|
|
711
|
+
// Every security property is unchanged. The ceiling still binds independently
|
|
712
|
+
// below. Reading still buys nothing back -- for a session with a long grace,
|
|
713
|
+
// max() returns the grace deadline it already had, so a read cannot extend a
|
|
714
|
+
// capability's life by even a millisecond. Once now + SESSION_IDLE_MS passes
|
|
715
|
+
// the original grace, this becomes an ordinary sliding window again.
|
|
716
|
+
s.expiresAt = Math.min(Math.max(s.expiresAt, now + SESSION_IDLE_MS), s.hardExpiresAt)
|
|
601
717
|
return s
|
|
602
718
|
}
|
|
603
719
|
|
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
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
completeEntry,
|
|
29
29
|
abortEntry,
|
|
30
30
|
createSession,
|
|
31
|
+
initialGraceMs,
|
|
31
32
|
peekSession,
|
|
32
33
|
rebindSessionHash,
|
|
33
34
|
reapExpiredSessions,
|
|
@@ -454,6 +455,9 @@ async function generateLocalIntoCache(
|
|
|
454
455
|
voice: string,
|
|
455
456
|
format: string,
|
|
456
457
|
signal?: AbortSignal,
|
|
458
|
+
// A user is waiting on a /play render; nobody is waiting on a pre-warm.
|
|
459
|
+
// Defaults to background so a new caller cannot accidentally starve playback.
|
|
460
|
+
priority = false,
|
|
457
461
|
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
458
462
|
// A memory/latency bound, NOT OpenAI's 4096. Kokoro reads what it is handed;
|
|
459
463
|
// the old shared cap truncated local speech at ~3-4 pages for no reason that
|
|
@@ -471,7 +475,7 @@ async function generateLocalIntoCache(
|
|
|
471
475
|
}
|
|
472
476
|
try {
|
|
473
477
|
// Local path ignores COS_VOICE_INSTRUCTIONS / per-request instructions.
|
|
474
|
-
const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal })
|
|
478
|
+
const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal, priority })
|
|
475
479
|
if (!bytes.length) {
|
|
476
480
|
abortEntry(hash)
|
|
477
481
|
return { ok: false, status: 502, message: 'local TTS returned empty body' }
|
|
@@ -496,10 +500,11 @@ async function generateIntoCache(
|
|
|
496
500
|
format: string,
|
|
497
501
|
instructions: string,
|
|
498
502
|
signal?: AbortSignal,
|
|
503
|
+
priority = false,
|
|
499
504
|
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
500
505
|
if (getCached(hash)) return { ok: true }
|
|
501
506
|
if (decision.backend === 'local') {
|
|
502
|
-
return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal)
|
|
507
|
+
return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal, priority)
|
|
503
508
|
}
|
|
504
509
|
return generateOpenAIIntoCache(
|
|
505
510
|
hash,
|
|
@@ -520,6 +525,9 @@ async function generateWithFallback(opts: {
|
|
|
520
525
|
enginePreference?: TtsEnginePreference | null
|
|
521
526
|
signal?: AbortSignal
|
|
522
527
|
sessionId?: string
|
|
528
|
+
/** True when a user is waiting (a /play cold miss). Background pre-warm
|
|
529
|
+
* leaves it false so playback can take the sidecar ahead of it. */
|
|
530
|
+
priority?: boolean
|
|
523
531
|
}): Promise<{ ok: true; hash: string } | { ok: false; status: number; message: string }> {
|
|
524
532
|
const enginePreference = opts.enginePreference ?? null
|
|
525
533
|
const preferOpenAI = enginePreference === 'openai'
|
|
@@ -553,6 +561,7 @@ async function generateWithFallback(opts: {
|
|
|
553
561
|
opts.format,
|
|
554
562
|
opts.instructions,
|
|
555
563
|
opts.signal,
|
|
564
|
+
opts.priority === true,
|
|
556
565
|
)
|
|
557
566
|
if (primary.ok) {
|
|
558
567
|
if (softEscapedToOpenAI) {
|
|
@@ -586,6 +595,7 @@ async function generateWithFallback(opts: {
|
|
|
586
595
|
opts.format,
|
|
587
596
|
opts.instructions,
|
|
588
597
|
opts.signal,
|
|
598
|
+
opts.priority === true,
|
|
589
599
|
)
|
|
590
600
|
if (localResult.ok) {
|
|
591
601
|
if (opts.sessionId) rebindSessionHash(opts.sessionId, localHash)
|
|
@@ -616,6 +626,7 @@ async function generateWithFallback(opts: {
|
|
|
616
626
|
opts.format,
|
|
617
627
|
opts.instructions,
|
|
618
628
|
opts.signal,
|
|
629
|
+
opts.priority === true,
|
|
619
630
|
)
|
|
620
631
|
if (openaiResult.ok) {
|
|
621
632
|
announceKokoroFallbackToOpenAI(failReason)
|
|
@@ -803,6 +814,7 @@ ttsRouter.post('/tts/stream', async (req, res) => {
|
|
|
803
814
|
voice: decision.backendVoice,
|
|
804
815
|
format: requestedFormat,
|
|
805
816
|
signal: upstreamController.signal,
|
|
817
|
+
priority: true,
|
|
806
818
|
})
|
|
807
819
|
if (upstreamController.signal.aborted) return
|
|
808
820
|
res.writeHead(200, {
|
|
@@ -857,12 +869,12 @@ ttsRouter.post('/tts/stream', async (req, res) => {
|
|
|
857
869
|
// hash the (text, voice, format) tuple, and return a session URL.
|
|
858
870
|
// 2. Client sets audio.src = `${apiBase}${sessionUrl}` and calls .play().
|
|
859
871
|
// 3. The browser GETs /api/tts/play/:session using the session as a bearer
|
|
860
|
-
// capability. Range refills may reuse it
|
|
872
|
+
// capability. Range refills may reuse it throughout its lifetime (see SESSION_IDLE_MS / initialGraceMs);
|
|
861
873
|
// the route serves cached bytes or starts live generation on a cold miss.
|
|
862
874
|
//
|
|
863
875
|
// The two-step pattern is required because authentication on the play route
|
|
864
876
|
// would force XHR (no Range support, no progressive decoding). The session
|
|
865
|
-
// UUID IS the auth — cryptographically random,
|
|
877
|
+
// UUID IS the auth — cryptographically random, bounded by SESSION_MAX_LIFETIME_MS, and scoped
|
|
866
878
|
// to one prepared audio item. It is re-readable only for native Range refills.
|
|
867
879
|
ttsRouter.post('/tts/prepare', async (req, res) => {
|
|
868
880
|
try {
|
|
@@ -906,6 +918,13 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
|
|
|
906
918
|
const preferOpenAI = enginePreference === 'openai'
|
|
907
919
|
const forceLocal = enginePreference === 'local'
|
|
908
920
|
|
|
921
|
+
// Every segment is minted here, but the client only touches segment k when
|
|
922
|
+
// segment k-1 starts playing -- minutes later for a long reply. So each
|
|
923
|
+
// session's first deadline is derived from the WHOLE reply's speaking time,
|
|
924
|
+
// not from a flat idle window that starts before anyone could read it. See
|
|
925
|
+
// initialGraceMs; this is the bug that stopped playback at segment 5 of 9.
|
|
926
|
+
const graceMs = initialGraceMs(capped.length)
|
|
927
|
+
|
|
909
928
|
const mintAndWarm = (chunk: string) => {
|
|
910
929
|
const hash = hashForDecision(decision, requestedFormat, chunk, requestedInstructions)
|
|
911
930
|
const uuid = createSession({
|
|
@@ -915,7 +934,7 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
|
|
|
915
934
|
format: requestedFormat,
|
|
916
935
|
preferOpenAI,
|
|
917
936
|
forceLocal,
|
|
918
|
-
})
|
|
937
|
+
}, { graceMs })
|
|
919
938
|
// Detached preparation is deliberately local-only. It must never retain
|
|
920
939
|
// authority to spend cloud budget after the client cancels or closes.
|
|
921
940
|
// OpenAI generation (including Kokoro fallback) begins only from the
|
|
@@ -1065,9 +1084,14 @@ function serveCachedBody(
|
|
|
1065
1084
|
ttsRouter.get('/tts/play/:session', async (req, res) => {
|
|
1066
1085
|
// peekSession (v5.9.4) — non-destructive lookup so iOS WKWebView can issue
|
|
1067
1086
|
// its routine HTTP Range requests for audio buffer refill without 404ing
|
|
1068
|
-
// halfway through a long playback. Sessions
|
|
1087
|
+
// halfway through a long playback. Sessions TTL out on the idle window, or on their derived grace if never read.
|
|
1069
1088
|
const session = peekSession(req.params.session)
|
|
1070
1089
|
if (!session) {
|
|
1090
|
+
// LOG IT. The server produced this 404 and recorded nothing, so the only
|
|
1091
|
+
// reporter was the client -- whose report is fire-and-forget, 3s-aborted and
|
|
1092
|
+
// error-deduped. Four builds on 2026-08-23 were spent inferring a fact the
|
|
1093
|
+
// server held the whole time.
|
|
1094
|
+
console.warn('[tts/play] 404 session expired or unknown:', req.params.session)
|
|
1071
1095
|
return res.status(404).json({ error: 'session expired or unknown' })
|
|
1072
1096
|
}
|
|
1073
1097
|
|
|
@@ -1112,6 +1136,8 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
|
|
|
1112
1136
|
enginePreference,
|
|
1113
1137
|
signal: upstreamController.signal,
|
|
1114
1138
|
sessionId: req.params.session,
|
|
1139
|
+
// /play cold miss: the listener is waiting on this render right now.
|
|
1140
|
+
priority: true,
|
|
1115
1141
|
})
|
|
1116
1142
|
|
|
1117
1143
|
if (!result.ok) {
|