@gotcos/glasses-server 6.36.24 → 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 CHANGED
@@ -1,3 +1,77 @@
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
+
1
75
  ## 6.36.24
2
76
 
3
77
  **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.26",
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.
@@ -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()
@@ -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
- 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 })
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: `/api/tts/play/${prefixMint.uuid}`,
947
- tailUrl: `/api/tts/play/${tailMint.uuid}`,
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) {