@gotcos/glasses-server 6.36.23 → 6.36.26
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 +108 -0
- package/package.json +1 -1
- package/server/lib/tts-cache.ts +79 -5
- package/server/routes/tts.ts +122 -95
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,111 @@
|
|
|
1
|
+
## 6.36.26
|
|
2
|
+
|
|
3
|
+
Deadline hardening for the segmented TTS path shipped in 6.36.25. Three constants
|
|
4
|
+
that were written rather than derived, and three tests that could not fail.
|
|
5
|
+
|
|
6
|
+
- `SESSION_IDLE_MS` 60s -> 120s, DERIVED. Every segment's session is minted at
|
|
7
|
+
`/prepare`, but the client only touches segment i+1 when segment i starts
|
|
8
|
+
playing -- so the idle window has to outlast one full segment at the slowest
|
|
9
|
+
speed the client offers: `900 / 19 / 0.5 = 94.7s`. 60s covered 1x (47.4s) and
|
|
10
|
+
1.25x (37.9s) but not 0.75x (63.2s), which is a shipped option in the Settings
|
|
11
|
+
picker. At 0.75x every other segment would have 404'd, and because the client
|
|
12
|
+
resolves rather than rejects on error, playback would have continued and
|
|
13
|
+
dropped half the reply while still sounding complete.
|
|
14
|
+
- `MAX_CHUNKS` 40 -> 46. 40 covered 35,350 characters against a 40,000-character
|
|
15
|
+
local cap -- 4,650 short, not "comfortably past" as its comment claimed. The
|
|
16
|
+
overflow landed in one oversized final segment, which the OpenAI backend then
|
|
17
|
+
trims PER SEGMENT, silently dropping text and contradicting the chunker's own
|
|
18
|
+
no-loss contract. (46 was the second answer; 45 was still 150 short.)
|
|
19
|
+
- The timing test compared one chunk's render time against its own playback
|
|
20
|
+
time. Both sides are linear in length, so it reduced to `1.9 < 17.54` and
|
|
21
|
+
passed for every input -- including `LATER_CHUNK_CHARS = 100_000`, which
|
|
22
|
+
restores the original bug exactly. Replaced with the cumulative, serialized
|
|
23
|
+
form the sidecar actually exhibits, plus a test that scores the OLD
|
|
24
|
+
prefix/tail split as the failure it was.
|
|
25
|
+
- Session-lifetime and policy tests now import `SESSION_IDLE_MS` and
|
|
26
|
+
`SESSION_MAX_LIFETIME_MS` instead of restating them as literals.
|
|
27
|
+
- `SESSION_MAX_LIFETIME_MS` 30 -> 90 minutes (derived: a 40,000-char reply is
|
|
28
|
+
70.2 minutes at 0.5x).
|
|
29
|
+
|
|
30
|
+
All four constants are mutation-verified: reverting each one fails a test.
|
|
31
|
+
|
|
32
|
+
215 files, 3028 tests.
|
|
33
|
+
|
|
34
|
+
## 6.36.25
|
|
35
|
+
|
|
36
|
+
**Spoken replies are now N segments, not a prefix and a tail.**
|
|
37
|
+
|
|
38
|
+
The two-segment split was a race, and the user lost it by less than a second.
|
|
39
|
+
Measured on device with a 6,781-character reply:
|
|
40
|
+
|
|
41
|
+
prefix rendered in 0.5s
|
|
42
|
+
tail rendered in 12.4s -- AFTER the prefix; the sidecar serializes
|
|
43
|
+
synthesis behind one lock
|
|
44
|
+
tail ready ~12.9s after prepare
|
|
45
|
+
prefix audio 15s ... but 12.0s at the user's 1.25x playback speed
|
|
46
|
+
|
|
47
|
+
The phone asked for the tail at 12.0s. It existed at 12.9s. `/play` blocks until
|
|
48
|
+
synthesis finishes before sending any headers -- 11.3 seconds to first byte,
|
|
49
|
+
measured -- and iOS's media loader will not wait. It buffered nothing and rejected
|
|
50
|
+
with `NotSupportedError`.
|
|
51
|
+
|
|
52
|
+
Widening the margin would not have fixed it: the margin depends on reply length,
|
|
53
|
+
voice, playback speed and machine load. Chunking removes the race instead. The
|
|
54
|
+
first segment stays small (250 chars, ~0.5s render, ~13s of speech) so first audio
|
|
55
|
+
is as fast as before; later segments are 900 chars. Every segment renders roughly
|
|
56
|
+
ten times faster than it plays, so the queue only gets further ahead — a property
|
|
57
|
+
now asserted directly rather than assumed.
|
|
58
|
+
|
|
59
|
+
`/prepare` returns `urls` (every segment, in order) plus `chunks`. `url` and
|
|
60
|
+
`tailUrl` are kept, pointing at the first two, so a client older than 6.8.428
|
|
61
|
+
plays a degraded two segments instead of nothing.
|
|
62
|
+
|
|
63
|
+
`splitForFastPrefix` is deleted — zero callers, zero tests, and leaving a
|
|
64
|
+
superseded splitter next to its replacement is how the wrong one gets used.
|
|
65
|
+
|
|
66
|
+
The chunker's first test asserts that concatenating the segments reproduces the
|
|
67
|
+
input's words. That caught a real defect in the first draft: the final piece was
|
|
68
|
+
appended twice, so `splitForChunks('Hi.')` returned `['Hi. Hi.']`. A chunker that
|
|
69
|
+
duplicates or drops text is worse than the bug it replaces, because the reply
|
|
70
|
+
still sounds complete.
|
|
71
|
+
|
|
72
|
+
Suite 3022 / 215, tsc 0. Mutation-verified: reverting to two segments, and
|
|
73
|
+
dropping the legacy url/tailUrl, each fail the route contract test.
|
|
74
|
+
|
|
75
|
+
## 6.36.24
|
|
76
|
+
|
|
77
|
+
**Playback stopped after about a minute, whatever the reply length.**
|
|
78
|
+
|
|
79
|
+
The TTS play session held a deadline fixed 60 seconds from creation. iOS WKWebView
|
|
80
|
+
re-requests `audio.src` every few seconds to refill its decode buffer, so once the
|
|
81
|
+
session expired those refills 404'd and the audio simply stopped mid-sentence.
|
|
82
|
+
|
|
83
|
+
Measured on this machine: 250 characters is 14 seconds of speech, 4,000 characters
|
|
84
|
+
is 211. So any reply over roughly 1,100 characters outlived its own session. The
|
|
85
|
+
symptom was "the first ten seconds play and nothing else comes" -- the fast-path
|
|
86
|
+
prefix is 250 characters, which is that 14 seconds exactly.
|
|
87
|
+
|
|
88
|
+
6.36.23 removed a character cap that was also real, but the cap truncated the
|
|
89
|
+
TEXT; this truncated the PLAYBACK. The ceiling was time, not length, which is what
|
|
90
|
+
"caps out at a max duration" meant literally.
|
|
91
|
+
|
|
92
|
+
`SESSION_TTL_MS` becomes `SESSION_IDLE_MS`, refreshed on every read, so a session
|
|
93
|
+
stays alive while audio is actively playing and dies a minute after it stops.
|
|
94
|
+
|
|
95
|
+
Because the session UUID IS the auth for an unauthenticated play route, a purely
|
|
96
|
+
sliding window could be held open indefinitely by polling. `SESSION_MAX_LIFETIME_MS`
|
|
97
|
+
(30 minutes) is an absolute ceiling that reading never extends -- far longer than
|
|
98
|
+
any plausible reply, and still a bounded exposure window for a leaked URL. The
|
|
99
|
+
periodic reaper honours it too.
|
|
100
|
+
|
|
101
|
+
v5.9.4 made these reads non-destructive for exactly this symptom and stopped one
|
|
102
|
+
step short, noting "sessions still expire on the existing 60s TTL, so the practical
|
|
103
|
+
exposure window is unchanged". True, and also what left the ceiling in place.
|
|
104
|
+
|
|
105
|
+
Suite 3013 / 214, tsc 0. Both halves mutation-verified: removing the refresh fails
|
|
106
|
+
the playback tests, and letting a read extend past the ceiling fails the security
|
|
107
|
+
tests.
|
|
108
|
+
|
|
1
109
|
## 6.36.23
|
|
2
110
|
|
|
3
111
|
**Long replies stopped speaking at about three or four pages.**
|
package/package.json
CHANGED
package/server/lib/tts-cache.ts
CHANGED
|
@@ -71,7 +71,10 @@ interface SessionEntry {
|
|
|
71
71
|
preferOpenAI?: boolean
|
|
72
72
|
/** Settings forced Local/Kokoro — do not auto-escape to OpenAI on play miss. */
|
|
73
73
|
forceLocal?: boolean
|
|
74
|
+
/** Idle deadline. Pushed out on every read; never past `hardExpiresAt`. */
|
|
74
75
|
expiresAt: number
|
|
76
|
+
/** Absolute deadline, fixed at creation. Reading never extends it. */
|
|
77
|
+
hardExpiresAt: number
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
interface DiskSidecar {
|
|
@@ -83,7 +86,67 @@ interface DiskSidecar {
|
|
|
83
86
|
|
|
84
87
|
const MAX_ENTRIES = 50
|
|
85
88
|
const MAX_TOTAL_BYTES = 100 * 1024 * 1024 // 100 MB — in-memory cap
|
|
86
|
-
|
|
89
|
+
/**
|
|
90
|
+
* IDLE timeout, not a lifetime. Refreshed on every read.
|
|
91
|
+
*
|
|
92
|
+
* It was a fixed 60s from creation, which silently capped PLAYBACK at 60
|
|
93
|
+
* seconds: iOS WKWebView re-requests `audio.src` every few seconds to refill
|
|
94
|
+
* its decode buffer, and once the session expired those refills 404'd and the
|
|
95
|
+
* audio simply stopped. Measured on this machine, 250 characters is 14 seconds
|
|
96
|
+
* of speech and 4,000 characters is 211 -- so any reply over roughly 1,100
|
|
97
|
+
* characters outlived its own session and cut off mid-sentence.
|
|
98
|
+
*
|
|
99
|
+
* v5.9.4 made reads non-destructive for exactly this reason and stopped one
|
|
100
|
+
* step short, noting "sessions still expire on the existing 60s TTL, so the
|
|
101
|
+
* practical exposure window is unchanged". That was true, and it is also what
|
|
102
|
+
* left the ceiling in place.
|
|
103
|
+
*/
|
|
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
|
+
})()
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Absolute ceiling, never refreshed.
|
|
127
|
+
*
|
|
128
|
+
* The session UUID IS the auth for an unauthenticated play route, so a purely
|
|
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.
|
|
148
|
+
*/
|
|
149
|
+
export const SESSION_MAX_LIFETIME_MS = 90 * 60_000
|
|
87
150
|
|
|
88
151
|
/** Disk cache configuration (env-overridable). Defaults sized for "I run this
|
|
89
152
|
* on my laptop and forget about it for months" rather than a service tier.
|
|
@@ -499,9 +562,14 @@ function sweepStaleByAge(): void {
|
|
|
499
562
|
* (hash, text, voice, format) bundle. The play route may reread it for native
|
|
500
563
|
* Range refills during the 60-second TTL; expired sessions are rejected and
|
|
501
564
|
* reaped by the periodic sweeper below. */
|
|
502
|
-
export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
|
|
565
|
+
export function createSession(s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>): string {
|
|
503
566
|
const uuid = randomUUID()
|
|
504
|
-
|
|
567
|
+
const now = Date.now()
|
|
568
|
+
sessions.set(uuid, {
|
|
569
|
+
...s,
|
|
570
|
+
expiresAt: now + SESSION_IDLE_MS,
|
|
571
|
+
hardExpiresAt: now + SESSION_MAX_LIFETIME_MS,
|
|
572
|
+
})
|
|
505
573
|
return uuid
|
|
506
574
|
}
|
|
507
575
|
|
|
@@ -520,10 +588,16 @@ export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
|
|
|
520
588
|
export function peekSession(uuid: string): SessionEntry | null {
|
|
521
589
|
const s = sessions.get(uuid)
|
|
522
590
|
if (!s) return null
|
|
523
|
-
|
|
591
|
+
const now = Date.now()
|
|
592
|
+
// Absolute ceiling first: a hard expiry must not be extendable by reading.
|
|
593
|
+
if (s.hardExpiresAt <= now || s.expiresAt < now) {
|
|
524
594
|
sessions.delete(uuid)
|
|
525
595
|
return null
|
|
526
596
|
}
|
|
597
|
+
// SLIDING. Every Range refill during playback pushes the idle deadline out, so
|
|
598
|
+
// a session lives as long as audio is actively being played and dies a minute
|
|
599
|
+
// after it stops -- never past the hard ceiling.
|
|
600
|
+
s.expiresAt = Math.min(now + SESSION_IDLE_MS, s.hardExpiresAt)
|
|
527
601
|
return s
|
|
528
602
|
}
|
|
529
603
|
|
|
@@ -552,7 +626,7 @@ export function consumeSession(uuid: string): SessionEntry | null {
|
|
|
552
626
|
export function reapExpiredSessions(): void {
|
|
553
627
|
const now = Date.now()
|
|
554
628
|
for (const [uuid, s] of sessions) {
|
|
555
|
-
if (s.expiresAt < now) sessions.delete(uuid)
|
|
629
|
+
if (s.hardExpiresAt <= now || s.expiresAt < now) sessions.delete(uuid)
|
|
556
630
|
}
|
|
557
631
|
}
|
|
558
632
|
|
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()
|
|
@@ -936,15 +948,30 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
|
|
|
936
948
|
return res.json({ url: `/api/tts/play/${uuid}`, ...engineMeta })
|
|
937
949
|
}
|
|
938
950
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
951
|
+
// N SEGMENTS, not two. See splitForChunks for why the prefix/tail pair was a
|
|
952
|
+
// race the user lost by less than a second.
|
|
953
|
+
//
|
|
954
|
+
// Warm order matters and is already correct: the sidecar serializes synthesis
|
|
955
|
+
// behind one lock, so minting in order means chunk 1 renders first and every
|
|
956
|
+
// later chunk finishes long before playback reaches it.
|
|
957
|
+
const chunks = splitForChunks(capped)
|
|
958
|
+
if (chunks.length === 0) {
|
|
959
|
+
return res.status(400).json({ error: 'text is required (non-empty string)' })
|
|
960
|
+
}
|
|
961
|
+
const mints = chunks.map((chunk) => mintAndWarm(chunk))
|
|
962
|
+
const urls = mints.map((m) => `/api/tts/play/${m.uuid}`)
|
|
963
|
+
|
|
964
|
+
if (urls.length === 1) {
|
|
965
|
+
return res.json({ url: urls[0], urls, chunks: 1, ...engineMeta })
|
|
943
966
|
}
|
|
944
|
-
const tailMint = mintAndWarm(tail)
|
|
945
967
|
res.json({
|
|
946
|
-
url
|
|
947
|
-
|
|
968
|
+
// `urls` is the real contract. `url` and `tailUrl` are kept so a client
|
|
969
|
+
// older than 6.8.428 still plays the first two segments instead of
|
|
970
|
+
// nothing -- degraded, but not broken, which is the point of keeping them.
|
|
971
|
+
url: urls[0],
|
|
972
|
+
tailUrl: urls[1],
|
|
973
|
+
urls,
|
|
974
|
+
chunks: urls.length,
|
|
948
975
|
...engineMeta,
|
|
949
976
|
})
|
|
950
977
|
} catch (err) {
|