@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 +73 -0
- package/package.json +1 -1
- package/server/lib/tts-cache.ts +44 -5
- 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,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
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|