@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 +39 -0
- package/package.json +1 -1
- package/server/lib/tts-engine.ts +41 -3
- package/server/routes/tts.ts +49 -13
- package/server/tts-sidecar/server.py +9 -2
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
package/server/lib/tts-engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
112
|
+
return KOKORO_VOICE_IDS.has(voice)
|
|
75
113
|
}
|
|
76
114
|
|
|
77
115
|
export function getTtsEngineMode(): TtsEngineMode {
|
package/server/routes/tts.ts
CHANGED
|
@@ -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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
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
|
|
98
|
-
function trimToCap(text: string): string {
|
|
99
|
-
if (text.length <=
|
|
100
|
-
const slice = text.slice(0,
|
|
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 >
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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)
|