@gotcos/glasses-server 6.36.22 → 6.36.24

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,76 @@
1
+ ## 6.36.24
2
+
3
+ **Playback stopped after about a minute, whatever the reply length.**
4
+
5
+ The TTS play session held a deadline fixed 60 seconds from creation. iOS WKWebView
6
+ re-requests `audio.src` every few seconds to refill its decode buffer, so once the
7
+ session expired those refills 404'd and the audio simply stopped mid-sentence.
8
+
9
+ Measured on this machine: 250 characters is 14 seconds of speech, 4,000 characters
10
+ is 211. So any reply over roughly 1,100 characters outlived its own session. The
11
+ symptom was "the first ten seconds play and nothing else comes" -- the fast-path
12
+ prefix is 250 characters, which is that 14 seconds exactly.
13
+
14
+ 6.36.23 removed a character cap that was also real, but the cap truncated the
15
+ TEXT; this truncated the PLAYBACK. The ceiling was time, not length, which is what
16
+ "caps out at a max duration" meant literally.
17
+
18
+ `SESSION_TTL_MS` becomes `SESSION_IDLE_MS`, refreshed on every read, so a session
19
+ stays alive while audio is actively playing and dies a minute after it stops.
20
+
21
+ Because the session UUID IS the auth for an unauthenticated play route, a purely
22
+ sliding window could be held open indefinitely by polling. `SESSION_MAX_LIFETIME_MS`
23
+ (30 minutes) is an absolute ceiling that reading never extends -- far longer than
24
+ any plausible reply, and still a bounded exposure window for a leaked URL. The
25
+ periodic reaper honours it too.
26
+
27
+ v5.9.4 made these reads non-destructive for exactly this symptom and stopped one
28
+ step short, noting "sessions still expire on the existing 60s TTL, so the practical
29
+ exposure window is unchanged". True, and also what left the ceiling in place.
30
+
31
+ Suite 3013 / 214, tsc 0. Both halves mutation-verified: removing the refresh fails
32
+ the playback tests, and letting a read extend past the ceiling fails the security
33
+ tests.
34
+
35
+ ## 6.36.23
36
+
37
+ **Long replies stopped speaking at about three or four pages.**
38
+
39
+ `MAX_TTS_CHARS = 4000` is OpenAI's input limit -- `gpt-4o-mini-tts` rejects
40
+ anything longer -- and it was applied UP FRONT, before COS chose an engine. Kokoro
41
+ runs locally and has no such limit, so local speech was being truncated by a rule
42
+ belonging to an API it was not using. The sidecar then applied its own `text[:4000]`
43
+ as a bare slice: no sentence boundary, no error, no signal to the caller. It simply
44
+ stopped mid-word.
45
+
46
+ The cap now lives where the backend is actually known. OpenAI keeps 4000, applied
47
+ in BOTH its entry points (the cached generator and the streaming sibling -- capping
48
+ one truncates silently through the other). Local gets 40,000, which is a memory and
49
+ latency bound rather than a product limit. The sidecar's slice is now a named
50
+ runaway-caller bound, overridable via `COS_TTS_MAX_INPUT_CHARS`.
51
+
52
+ **Eight British English voices**, on disk all along and offered by nothing:
53
+ bm_george, bm_daniel, bm_lewis, bm_fable, bf_emma, bf_alice, bf_isabella, bf_lily.
54
+ `/api/tts/voices` now serves 28 local voices with an `accent` field, American
55
+ first so `local[0]` is still the historical default.
56
+
57
+ The voice pack ships 54. The other 26 -- Mandarin, Japanese, Hindi, Spanish,
58
+ Brazilian Portuguese, Italian, French -- are deliberately NOT offered: the sidecar
59
+ phonemises with `lang_code="a"`, so they would be read through an American English
60
+ grapheme-to-phoneme pass, producing an accent artefact rather than the language.
61
+ Exposing them needs a lang_code map and text in the matching language.
62
+
63
+ **`isKokoroVoiceId` now checks the catalog instead of the shape.** It was
64
+ `/^[a-z]{2}_[a-z0-9]+$/i`, which accepts any id of the right form -- the 26
65
+ non-English voices, and ids for no voice at all. Nothing downstream refused them
66
+ either: the sidecar falls back requested -> COS_TTS_KOKORO_VOICE -> am_echo and
67
+ returns audio, so an unrecognised voice produced a DIFFERENT voice with no error.
68
+ `KOKORO_VOICE_IDS` had existed for exactly this check and was never read.
69
+
70
+ Suite 3008 / 213, tsc 0. Three mutations verified: restoring the shared cap on the
71
+ local path, reintroducing the up-front cap, and dropping the British set each fail
72
+ the assertion written for them.
73
+
1
74
  ## 6.36.22
2
75
 
3
76
  **`features.claudeSessions` in health**, so a client toggle can read its own state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.22",
3
+ "version": "6.36.24",
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": {
@@ -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,32 @@ 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
- const SESSION_TTL_MS = 60_000
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
+ const SESSION_IDLE_MS = 60_000
105
+
106
+ /**
107
+ * Absolute ceiling, never refreshed.
108
+ *
109
+ * 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.
113
+ */
114
+ const SESSION_MAX_LIFETIME_MS = 30 * 60_000
87
115
 
88
116
  /** Disk cache configuration (env-overridable). Defaults sized for "I run this
89
117
  * on my laptop and forget about it for months" rather than a service tier.
@@ -499,9 +527,14 @@ function sweepStaleByAge(): void {
499
527
  * (hash, text, voice, format) bundle. The play route may reread it for native
500
528
  * Range refills during the 60-second TTL; expired sessions are rejected and
501
529
  * reaped by the periodic sweeper below. */
502
- export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
530
+ export function createSession(s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>): string {
503
531
  const uuid = randomUUID()
504
- sessions.set(uuid, { ...s, expiresAt: Date.now() + SESSION_TTL_MS })
532
+ const now = Date.now()
533
+ sessions.set(uuid, {
534
+ ...s,
535
+ expiresAt: now + SESSION_IDLE_MS,
536
+ hardExpiresAt: now + SESSION_MAX_LIFETIME_MS,
537
+ })
505
538
  return uuid
506
539
  }
507
540
 
@@ -520,10 +553,16 @@ export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
520
553
  export function peekSession(uuid: string): SessionEntry | null {
521
554
  const s = sessions.get(uuid)
522
555
  if (!s) return null
523
- if (s.expiresAt < Date.now()) {
556
+ const now = Date.now()
557
+ // Absolute ceiling first: a hard expiry must not be extendable by reading.
558
+ if (s.hardExpiresAt <= now || s.expiresAt < now) {
524
559
  sessions.delete(uuid)
525
560
  return null
526
561
  }
562
+ // SLIDING. Every Range refill during playback pushes the idle deadline out, so
563
+ // a session lives as long as audio is actively being played and dies a minute
564
+ // after it stops -- never past the hard ceiling.
565
+ s.expiresAt = Math.min(now + SESSION_IDLE_MS, s.hardExpiresAt)
527
566
  return s
528
567
  }
529
568
 
@@ -552,7 +591,7 @@ export function consumeSession(uuid: string): SessionEntry | null {
552
591
  export function reapExpiredSessions(): void {
553
592
  const now = Date.now()
554
593
  for (const [uuid, s] of sessions) {
555
- if (s.expiresAt < now) sessions.delete(uuid)
594
+ if (s.hardExpiresAt <= now || s.expiresAt < now) sessions.delete(uuid)
556
595
  }
557
596
  }
558
597
 
@@ -47,6 +47,29 @@ export const KOKORO_VOICE_OPTIONS = [
47
47
  { id: 'af_river', label: 'River (af_river)' },
48
48
  ] as const
49
49
 
50
+ /** British English presets, verified on disk in the same voice pack.
51
+ *
52
+ * Kept a SEPARATE list rather than folded into the American one: the picker
53
+ * groups by accent, and a caller that wants "the default set" should not silently
54
+ * get a British voice. All 54 packaged voices were considered; only these 8 are
55
+ * added, because the sidecar phonemises with `lang_code="a"` and the remaining 26
56
+ * (Mandarin, Japanese, Hindi, Spanish, Portuguese, Italian, French) would be read
57
+ * through an American English grapheme-to-phoneme pass. That produces an accent
58
+ * artefact, not the language. Exposing them needs a lang_code map and text in the
59
+ * matching language -- deliberately out of scope, not overlooked. */
60
+ export const KOKORO_EN_GB_VOICE_OPTIONS = [
61
+ { id: 'bm_george', label: 'George (bm_george) · British' },
62
+ { id: 'bm_daniel', label: 'Daniel (bm_daniel) · British' },
63
+ { id: 'bm_lewis', label: 'Lewis (bm_lewis) · British' },
64
+ { id: 'bm_fable', label: 'Fable (bm_fable) · British' },
65
+ { id: 'bf_emma', label: 'Emma (bf_emma) · British' },
66
+ { id: 'bf_alice', label: 'Alice (bf_alice) · British' },
67
+ { id: 'bf_isabella', label: 'Isabella (bf_isabella) · British' },
68
+ { id: 'bf_lily', label: 'Lily (bf_lily) · British' },
69
+ ] as const
70
+
71
+ export const KOKORO_EN_GB_VOICES = KOKORO_EN_GB_VOICE_OPTIONS.map((v) => v.id)
72
+
50
73
  export const KOKORO_EN_US_VOICES = KOKORO_VOICE_OPTIONS.map((v) => v.id)
51
74
 
52
75
  // OpenAI id → Kokoro when Settings still sends an OpenAI id on the local path.
@@ -63,15 +86,30 @@ const VOICE_MAP: Record<string, string> = {
63
86
  }
64
87
 
65
88
  const OPENAI_VOICE_IDS = new Set(OPENAI_VOICE_OPTIONS.map((v) => v.id))
66
- const KOKORO_VOICE_IDS = new Set(KOKORO_EN_US_VOICES)
89
+ // BOTH accents, and this Set is now what `isKokoroVoiceId` actually checks.
90
+ // Omitting either list here would make the picker offer voices the server then
91
+ // refuses -- the UI and the server disagreeing in silence.
92
+ const KOKORO_VOICE_IDS = new Set<string>([...KOKORO_EN_US_VOICES, ...KOKORO_EN_GB_VOICES])
67
93
 
68
94
  export function isOpenAIVoiceId(voice: string): boolean {
69
95
  return OPENAI_VOICE_IDS.has(voice as typeof OPENAI_VOICE_OPTIONS[number]['id'])
70
96
  }
71
97
 
72
- /** Kokoro / Misaki voice file ids look like am_echo, af_heart, bm_george. */
98
+ /**
99
+ * Is this a Kokoro voice COS actually offers?
100
+ *
101
+ * THE CATALOG, not the shape. This was `/^[a-z]{2}_[a-z0-9]+$/i`, which accepts
102
+ * any id of the right form -- including the 26 non-English voices in the same
103
+ * pack, and including ids for no voice at all. Neither is refused anywhere
104
+ * downstream: the sidecar's `synthesize` falls back through
105
+ * requested -> COS_TTS_KOKORO_VOICE -> am_echo and returns audio, so an
106
+ * unrecognised voice produced a DIFFERENT voice with no error. The caller asked
107
+ * for one thing, got another, and nothing said so.
108
+ *
109
+ * `KOKORO_VOICE_IDS` existed for this and was never read.
110
+ */
73
111
  export function isKokoroVoiceId(voice: string): boolean {
74
- return /^[a-z]{2}_[a-z0-9]+$/i.test(voice)
112
+ return KOKORO_VOICE_IDS.has(voice)
75
113
  }
76
114
 
77
115
  export function getTtsEngineMode(): TtsEngineMode {
@@ -41,6 +41,7 @@ import {
41
41
  getTtsEngineMode,
42
42
  isKokoroVoiceId,
43
43
  isOpenAIVoiceId,
44
+ KOKORO_EN_GB_VOICE_OPTIONS,
44
45
  KOKORO_VOICE_OPTIONS,
45
46
  mapOpenAIVoiceToLocal,
46
47
  OPENAI_VOICE_OPTIONS,
@@ -63,10 +64,23 @@ export const ttsRouter = Router()
63
64
  // process lifetime — no teardown needed.
64
65
  setInterval(reapExpiredSessions, 30_000).unref()
65
66
 
66
- // Hard text length cap OpenAI gpt-4o-mini-tts accepts up to 4096 input chars.
67
- // Anything longer would be rejected; we trim defensively at a sentence boundary
68
- // near the cap so the audio doesn't end mid-word.
69
- const MAX_TTS_CHARS = 4000
67
+ // THE CAP IS PER BACKEND, and it is applied where the backend is known.
68
+ //
69
+ // 4000 is OpenAI's constraint: gpt-4o-mini-tts rejects input over 4096 chars.
70
+ // Kokoro has no such limit -- it is a local model reading whatever it is handed.
71
+ // This cap used to be applied up front, before the engine was chosen, so a local
72
+ // synthesis was silently truncated at ~3-4 pages by a rule belonging to an API it
73
+ // was not using. Long replies just stopped mid-thought.
74
+ //
75
+ // Applied in the generators instead, because the backend is not settled until
76
+ // then: a local request can still fall back to OpenAI when Kokoro is unavailable,
77
+ // and that fallback must obey OpenAI's limit even though the request did not.
78
+ const MAX_OPENAI_TTS_CHARS = 4000
79
+
80
+ // Not a product limit -- a memory and latency bound. 40k chars is roughly 40
81
+ // minutes of speech and several hundred MB of PCM before encode. Past that, a
82
+ // runaway caller is the likelier explanation than a real request.
83
+ const MAX_LOCAL_TTS_CHARS = 40_000
70
84
 
71
85
  // OpenAI voice IDs supported by gpt-4o-mini-tts. Default is alloy (warm,
72
86
  // neutral, gender-neutral). Voice can be overridden per-request and the
@@ -94,13 +108,13 @@ const FORMAT_MIME: Record<string, string> = {
94
108
  pcm: 'audio/pcm',
95
109
  }
96
110
 
97
- /** Trim text to MAX_TTS_CHARS at a sentence boundary if possible. */
98
- function trimToCap(text: string): string {
99
- if (text.length <= MAX_TTS_CHARS) return text
100
- const slice = text.slice(0, MAX_TTS_CHARS)
111
+ /** Trim to `cap` at a sentence boundary if possible, else a word boundary. */
112
+ function trimToCap(text: string, cap: number): string {
113
+ if (text.length <= cap) return text
114
+ const slice = text.slice(0, cap)
101
115
  // Walk back to the last sentence terminator (.!?) to avoid mid-word cuts.
102
116
  const lastTerm = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('! '), slice.lastIndexOf('? '))
103
- if (lastTerm > MAX_TTS_CHARS * 0.6) return slice.slice(0, lastTerm + 1)
117
+ if (lastTerm > cap * 0.6) return slice.slice(0, lastTerm + 1)
104
118
  // Fall back to the last word boundary.
105
119
  const lastSpace = slice.lastIndexOf(' ')
106
120
  return lastSpace > 0 ? slice.slice(0, lastSpace) : slice
@@ -332,6 +346,10 @@ async function generateOpenAIIntoCache(
332
346
  instructions: string,
333
347
  signal?: AbortSignal,
334
348
  ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
349
+ // OpenAI's own limit, applied HERE because this is the first point that knows
350
+ // OpenAI is what actually runs. A local request that fell back to OpenAI arrives
351
+ // uncapped and must still be trimmed.
352
+ text = trimToCap(text, MAX_OPENAI_TTS_CHARS)
335
353
  let key: string
336
354
  try {
337
355
  key = getOpenAIKey()
@@ -425,6 +443,10 @@ async function generateLocalIntoCache(
425
443
  format: string,
426
444
  signal?: AbortSignal,
427
445
  ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
446
+ // A memory/latency bound, NOT OpenAI's 4096. Kokoro reads what it is handed;
447
+ // the old shared cap truncated local speech at ~3-4 pages for no reason that
448
+ // applied to it.
449
+ text = trimToCap(text, MAX_LOCAL_TTS_CHARS)
428
450
  if (!isLocalTtsReady()) {
429
451
  return { ok: false, status: 503, message: 'local TTS sidecar not ready' }
430
452
  }
@@ -601,7 +623,14 @@ ttsRouter.get('/tts/voices', (_req, res) => {
601
623
  : 'local',
602
624
  localReady: isLocalTtsReady(),
603
625
  openai: OPENAI_VOICE_OPTIONS,
604
- local: KOKORO_VOICE_OPTIONS,
626
+ // American first, then British. ONE list rather than a new key, so an existing
627
+ // client picks up the extra voices without a change, and `local[0]` stays the
628
+ // default it always was. Each entry's label carries the accent; `accent` is
629
+ // there so a picker can group without parsing the id prefix.
630
+ local: [
631
+ ...KOKORO_VOICE_OPTIONS.map((v) => ({ ...v, accent: 'en-US' as const })),
632
+ ...KOKORO_EN_GB_VOICE_OPTIONS.map((v) => ({ ...v, accent: 'en-GB' as const })),
633
+ ],
605
634
  })
606
635
  })
607
636
 
@@ -618,6 +647,9 @@ async function streamOpenAIToResponse(
618
647
  onFirstByte?: () => void
619
648
  },
620
649
  ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
650
+ // Same limit, the streaming sibling. Two OpenAI entry points, so the cap has to
651
+ // exist at both -- capping only the cached one would truncate silently here.
652
+ const text = trimToCap(opts.text, MAX_OPENAI_TTS_CHARS)
621
653
  let key: string
622
654
  try {
623
655
  key = getOpenAIKey()
@@ -629,7 +661,7 @@ async function streamOpenAIToResponse(
629
661
  return { ok: false, status: 503, message: errMsg(err) }
630
662
  }
631
663
 
632
- const spoken = applyOpenAIPronunciation(opts.text)
664
+ const spoken = applyOpenAIPronunciation(text)
633
665
  let upstream: Response
634
666
  try {
635
667
  upstream = await fetch('https://api.openai.com/v1/audio/speech', {
@@ -715,7 +747,9 @@ ttsRouter.post('/tts/stream', async (req, res) => {
715
747
  ? instructions : DEFAULT_INSTRUCTIONS
716
748
 
717
749
  const cleaned = stripMarkdownLight(text).trim()
718
- const capped = trimToCap(cleaned)
750
+ // NOT capped here -- the backend is not known yet, and the OpenAI limit does
751
+ // not apply to Kokoro. The generators cap for whichever backend actually runs.
752
+ const capped = cleaned
719
753
 
720
754
  const upstreamController = new AbortController()
721
755
  res.once('close', () => {
@@ -854,7 +888,9 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
854
888
  }
855
889
 
856
890
  const cleaned = stripMarkdownLight(text).trim()
857
- const capped = trimToCap(cleaned)
891
+ // NOT capped here -- the backend is not known yet, and the OpenAI limit does
892
+ // not apply to Kokoro. The generators cap for whichever backend actually runs.
893
+ const capped = cleaned
858
894
  const preferOpenAI = enginePreference === 'openai'
859
895
  const forceLocal = enginePreference === 'local'
860
896
 
@@ -31,6 +31,9 @@ PROTOCOL = "cos-tts-v1"
31
31
  AUTH_TOKEN = os.environ.get("COS_TTS_AUTH_TOKEN", "")
32
32
  MODEL_ID = os.environ.get("COS_TTS_KOKORO_MODEL", "mlx-community/Kokoro-82M-bf16")
33
33
  DEFAULT_VOICE = os.environ.get("COS_TTS_KOKORO_VOICE", "am_echo")
34
+ # Runaway-caller bound only. Roughly 40 minutes of speech; the server applies
35
+ # the real per-backend cap before calling here.
36
+ MAX_INPUT_CHARS = int(os.environ.get("COS_TTS_MAX_INPUT_CHARS", "40000"))
34
37
  SAMPLE_RATE = 24_000
35
38
 
36
39
  _model = None
@@ -210,8 +213,12 @@ def speech(req: SpeechRequest, authorization: str | None = Header(default=None))
210
213
  text = req.input.strip()
211
214
  if not text:
212
215
  raise HTTPException(status_code=400, detail="input is required")
213
- if len(text) > 4000:
214
- text = text[:4000]
216
+ # 4000 was OpenAI's input limit, applied here to a LOCAL model that has no
217
+ # such constraint -- and applied as a bare slice, so a long reply stopped
218
+ # mid-word with no error and no signal to the caller. The server now caps per
219
+ # backend before it calls us; this is only a runaway-caller bound.
220
+ if len(text) > MAX_INPUT_CHARS:
221
+ text = text[:MAX_INPUT_CHARS]
215
222
  try:
216
223
  audio = synthesize(text, req.voice, req.speed)
217
224
  body, mime = encode_audio(audio, req.response_format)