@gotcos/glasses-server 6.36.22 → 6.36.23

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,42 @@
1
+ ## 6.36.23
2
+
3
+ **Long replies stopped speaking at about three or four pages.**
4
+
5
+ `MAX_TTS_CHARS = 4000` is OpenAI's input limit -- `gpt-4o-mini-tts` rejects
6
+ anything longer -- and it was applied UP FRONT, before COS chose an engine. Kokoro
7
+ runs locally and has no such limit, so local speech was being truncated by a rule
8
+ belonging to an API it was not using. The sidecar then applied its own `text[:4000]`
9
+ as a bare slice: no sentence boundary, no error, no signal to the caller. It simply
10
+ stopped mid-word.
11
+
12
+ The cap now lives where the backend is actually known. OpenAI keeps 4000, applied
13
+ in BOTH its entry points (the cached generator and the streaming sibling -- capping
14
+ one truncates silently through the other). Local gets 40,000, which is a memory and
15
+ latency bound rather than a product limit. The sidecar's slice is now a named
16
+ runaway-caller bound, overridable via `COS_TTS_MAX_INPUT_CHARS`.
17
+
18
+ **Eight British English voices**, on disk all along and offered by nothing:
19
+ bm_george, bm_daniel, bm_lewis, bm_fable, bf_emma, bf_alice, bf_isabella, bf_lily.
20
+ `/api/tts/voices` now serves 28 local voices with an `accent` field, American
21
+ first so `local[0]` is still the historical default.
22
+
23
+ The voice pack ships 54. The other 26 -- Mandarin, Japanese, Hindi, Spanish,
24
+ Brazilian Portuguese, Italian, French -- are deliberately NOT offered: the sidecar
25
+ phonemises with `lang_code="a"`, so they would be read through an American English
26
+ grapheme-to-phoneme pass, producing an accent artefact rather than the language.
27
+ Exposing them needs a lang_code map and text in the matching language.
28
+
29
+ **`isKokoroVoiceId` now checks the catalog instead of the shape.** It was
30
+ `/^[a-z]{2}_[a-z0-9]+$/i`, which accepts any id of the right form -- the 26
31
+ non-English voices, and ids for no voice at all. Nothing downstream refused them
32
+ either: the sidecar falls back requested -> COS_TTS_KOKORO_VOICE -> am_echo and
33
+ returns audio, so an unrecognised voice produced a DIFFERENT voice with no error.
34
+ `KOKORO_VOICE_IDS` had existed for exactly this check and was never read.
35
+
36
+ Suite 3008 / 213, tsc 0. Three mutations verified: restoring the shared cap on the
37
+ local path, reintroducing the up-front cap, and dropping the British set each fail
38
+ the assertion written for them.
39
+
1
40
  ## 6.36.22
2
41
 
3
42
  **`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.23",
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": {
@@ -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)