@gotcos/glasses-server 6.14.1 → 6.15.0
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/.env.example +11 -0
- package/CHANGELOG.md +27 -0
- package/README.md +29 -7
- package/bin/cli.cjs +17 -4
- package/package.json +2 -2
- package/server/index.ts +3 -0
- package/server/lib/tts-cache.ts +28 -2
- package/server/lib/tts-engine.ts +230 -0
- package/server/lib/tts-local.ts +375 -0
- package/server/lib/tts-pronounce.ts +53 -0
- package/server/lib/whisper-local.ts +86 -4
- package/server/routes/health.ts +8 -2
- package/server/routes/tts.ts +574 -196
- package/server/tts-sidecar/bootstrap.sh +33 -0
- package/server/tts-sidecar/requirements.txt +9 -0
- package/server/tts-sidecar/server.py +235 -0
package/.env.example
CHANGED
|
@@ -70,6 +70,17 @@ BIND_HOST=0.0.0.0
|
|
|
70
70
|
# COS_OPENAI_WHISPER_FALLBACK=1
|
|
71
71
|
# OPENAI_API_KEY=sk-...
|
|
72
72
|
|
|
73
|
+
# Spoken reply playback defaults to local Kokoro on Apple silicon Macs. The
|
|
74
|
+
# first run creates a private venv and downloads the model. Local mode fails
|
|
75
|
+
# closed: it never sends text to OpenAI. Set openai_primary only when cloud TTS
|
|
76
|
+
# is desired and OPENAI_API_KEY is configured.
|
|
77
|
+
# COS_TTS_ENGINE=local_first # local_first | openai_primary
|
|
78
|
+
# COS_TTS_LOCAL_PORT=8179
|
|
79
|
+
# COS_TTS_LOCAL_TIMEOUT_MS=12000
|
|
80
|
+
# COS_TTS_KOKORO_VOICE=am_echo
|
|
81
|
+
# COS_TTS_LOCAL_DISABLE=1 # disable the local sidecar explicitly
|
|
82
|
+
# COS_TTS_PRONUNCIATIONS_JSON={"Exampleco":{"local":"[Exampleco](/ɪgzˈæmpəlkoʊ/)","openai":"ig-ZAM-pul-co"}}
|
|
83
|
+
|
|
73
84
|
# ── FULL COS PIPELINE (optional — leave unset for standalone) ────────────
|
|
74
85
|
# Power users running the COS Starter Kit can point the glasses at their
|
|
75
86
|
# pipeline to inherit live tasks/calendar/people context. Omit for standalone.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
## 6.15.0
|
|
2
|
+
|
|
3
|
+
- Add local-first spoken reply playback through a Mac-owned Kokoro sidecar on
|
|
4
|
+
Apple silicon. The existing OpenAI TTS path remains available as an explicit
|
|
5
|
+
engine choice and as the bounded fallback for `local_first` only when a key
|
|
6
|
+
and budget are available.
|
|
7
|
+
- Preserve cancellation across the local synthesis boundary so abandoned
|
|
8
|
+
requests return 499 and can never become accidental cloud fallbacks. An
|
|
9
|
+
explicitly selected local engine fails closed when Kokoro is unavailable.
|
|
10
|
+
- Keep cache entries and resumable sessions engine-aware, expose additive
|
|
11
|
+
`tts_local` and voice-engine health, and announce a real Kokoro-to-OpenAI
|
|
12
|
+
fallback only after cloud audio succeeds.
|
|
13
|
+
- Bootstrap the pinned private Python environment asynchronously on first run,
|
|
14
|
+
avoiding an API event-loop stall while dependencies install. The local
|
|
15
|
+
sidecar is limited to Apple silicon macOS; other hosts retain their existing
|
|
16
|
+
OpenAI behavior.
|
|
17
|
+
- Add opt-in, public-safe pronunciation overrides through
|
|
18
|
+
`COS_TTS_PRONUNCIATIONS_JSON`. The published package contains no personal
|
|
19
|
+
pronunciation dictionary or machine-specific path.
|
|
20
|
+
|
|
21
|
+
## 6.14.2
|
|
22
|
+
|
|
23
|
+
- Restore timed words on post-meeting CPU polish only: whisper-cli `-ojf`
|
|
24
|
+
token offsets feed speaker-word mapping after save. Live ASR stays on
|
|
25
|
+
compact `json` (no `verbose_json`); VAD-empty CLI windows return empty
|
|
26
|
+
transcription safely without reintroducing the live daemon crash.
|
|
27
|
+
|
|
1
28
|
## 6.14.1
|
|
2
29
|
|
|
3
30
|
- Keep real-time `large-v3-turbo` stable on VAD-empty audio by using
|
package/README.md
CHANGED
|
@@ -11,8 +11,8 @@ API key is pasted into the phone for chat.
|
|
|
11
11
|
npx --yes @gotcos/glasses-server@latest
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
For the optional COS Control macOS menu bar app,
|
|
15
|
-
|
|
14
|
+
For the optional COS Control macOS menu bar app, run the same non-mutating
|
|
15
|
+
readiness check it uses before guided installation:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
npx --yes @gotcos/glasses-server@latest --prepare-only
|
|
@@ -21,9 +21,13 @@ npx --yes @gotcos/glasses-server@latest --prepare-only
|
|
|
21
21
|
COS Control then installs the same npm package as a launchd-managed runtime.
|
|
22
22
|
The original foreground command remains supported and unchanged.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
Normal server start checks Node, finds your CLI, checks voice and image
|
|
25
|
+
processing, writes `~/.cos-glasses/.env`, and starts the server on
|
|
26
|
+
`0.0.0.0:3141`. Optional Whisper and Kokoro models are provisioned when their
|
|
27
|
+
local services start; `--prepare-only` intentionally does not download or
|
|
28
|
+
install optional models, write COS configuration, or start a listener. It may
|
|
29
|
+
invoke an installed agent CLI's read-only version/auth probe, and that CLI may
|
|
30
|
+
maintain its own user cache. On boot the server prints
|
|
27
31
|
an **API token** — paste that into the COS Glasses app. Only one COS Glasses
|
|
28
32
|
server may run on a Mac at a time; a second `npx` or source runner exits before
|
|
29
33
|
opening ports or touching shared conversation/media state. Version 6.6.0 also
|
|
@@ -41,7 +45,10 @@ without silently losing completed replies.
|
|
|
41
45
|
_or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
|
|
42
46
|
- **Even G2 glasses** + the **COS Glasses** app from the Even Hub
|
|
43
47
|
- `brew install whisper-cpp` for free local voice (the launcher can download the model)
|
|
44
|
-
- _Optional:_ `brew install ffmpeg` for
|
|
48
|
+
- _Optional:_ `brew install python@3.13 ffmpeg espeak-ng` for local Kokoro
|
|
49
|
+
spoken replies on Apple silicon (Python 3.11-3.13 is supported). `ffmpeg`
|
|
50
|
+
also enables phone/output image attachments; text chat remains available
|
|
51
|
+
without these optional dependencies.
|
|
45
52
|
- _Optional:_ **Tailscale** so your phone reaches your Mac from anywhere
|
|
46
53
|
|
|
47
54
|
> No `ANTHROPIC_API_KEY` is needed — chat runs through your installed CLI, billed
|
|
@@ -99,6 +106,11 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
|
|
|
99
106
|
Whisper fallback is optional and requires both the exact
|
|
100
107
|
`COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
|
|
101
108
|
uploads audio.
|
|
109
|
+
- Local-first spoken reply playback through Kokoro on Apple silicon. The first
|
|
110
|
+
use creates a private Python environment and downloads its model without
|
|
111
|
+
blocking the API. Selecting Local fails closed; `local_first` can fall back
|
|
112
|
+
to OpenAI TTS only when a key and budget are available. `/api/health`
|
|
113
|
+
reports the independent `tts_local` state and current engine.
|
|
102
114
|
- Tasks / calendar / people context **if** you run the
|
|
103
115
|
[COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
|
|
104
116
|
glasses + AI only
|
|
@@ -108,7 +120,11 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
|
|
|
108
120
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
|
109
121
|
optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
|
|
110
122
|
`COS_API_TOKEN` (auto if unset), `COS_OPENAI_WHISPER_FALLBACK=1` plus
|
|
111
|
-
`OPENAI_API_KEY` (explicit cloud
|
|
123
|
+
`OPENAI_API_KEY` (explicit cloud transcription/TTS fallback),
|
|
124
|
+
`COS_TTS_ENGINE` (`local_first` or `openai_primary`),
|
|
125
|
+
`COS_TTS_KOKORO_VOICE` (local voice id),
|
|
126
|
+
`COS_TTS_LOCAL_DISABLE=1` (disable the sidecar), and
|
|
127
|
+
`COS_TTS_PRONUNCIATIONS_JSON` (optional local/cloud pronunciation overrides),
|
|
112
128
|
`COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
|
|
113
129
|
server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
|
|
114
130
|
location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
|
|
@@ -149,6 +165,12 @@ BIND_HOST=0.0.0.0 npm run start:server
|
|
|
149
165
|
confirm `/api/health` reports `features.whisper: true`. A typed retryable 503
|
|
150
166
|
keeps compatible prompt/meeting audio available for retry instead of silently
|
|
151
167
|
sending it to OpenAI.
|
|
168
|
+
- *Local spoken replies unavailable?* — on Apple silicon, install
|
|
169
|
+
`python@3.13 ffmpeg espeak-ng`, restart the server, and wait for the
|
|
170
|
+
first-run Kokoro model download. Confirm `/api/health` reports
|
|
171
|
+
`tts_local.ready: true`.
|
|
172
|
+
Selecting Local never falls back to cloud; set `COS_TTS_ENGINE=openai_primary`
|
|
173
|
+
only when OpenAI playback is intentionally configured.
|
|
152
174
|
- *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
|
|
153
175
|
- *Prompt recovery unavailable?* — update with `npx --yes @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
|
|
154
176
|
- *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
|
package/bin/cli.cjs
CHANGED
|
@@ -47,6 +47,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
47
47
|
console.log(' - Node.js 20.11+')
|
|
48
48
|
console.log(' - Claude Code CLI (not Claude Desktop) or Codex CLI')
|
|
49
49
|
console.log(' - Even G2 smart glasses + the COS Glasses app (Even Hub)')
|
|
50
|
+
console.log(' - Optional local Kokoro voice: Apple silicon + Python 3.11-3.13')
|
|
50
51
|
console.log('')
|
|
51
52
|
console.log(' No API key is needed for chat — it runs through your installed CLI.')
|
|
52
53
|
console.log(' Config persists at ~/.cos-glasses/.env')
|
|
@@ -175,13 +176,25 @@ try {
|
|
|
175
176
|
process.exit(1)
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
// Controller probes
|
|
179
|
-
// and the packaged runtime without creating config, downloading
|
|
180
|
-
// changing permissions, or starting a
|
|
179
|
+
// Controller probes do not mutate COS state. They verify Node, agent auth,
|
|
180
|
+
// and the packaged runtime without creating COS config, downloading Kokoro
|
|
181
|
+
// models, installing Kokoro packages, changing COS permissions, or starting a
|
|
182
|
+
// listener. An invoked agent CLI may still maintain its own user cache.
|
|
181
183
|
if (process.argv.includes('--prepare-only')) {
|
|
184
|
+
if (process.platform === 'darwin' && process.arch === 'arm64') {
|
|
185
|
+
const python = ['python3.13', 'python3.12', 'python3.11']
|
|
186
|
+
.map((command) => ({ command, version: getCliVersion(command, '--version') }))
|
|
187
|
+
.find((candidate) => candidate.version)
|
|
188
|
+
if (python) {
|
|
189
|
+
console.log(green(' ✓') + ` Local voice Python ${python.version.replace(/^Python\s+/i, '')}`)
|
|
190
|
+
} else {
|
|
191
|
+
console.log(yellow(' ⚠') + ' Local Kokoro voice needs Python 3.11-3.13')
|
|
192
|
+
console.log(' Install: ' + bold('brew install python@3.13 ffmpeg espeak-ng'))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
182
195
|
console.log('')
|
|
183
196
|
console.log(green(' ✓ Non-mutating readiness check complete'))
|
|
184
|
-
console.log('
|
|
197
|
+
console.log(' This checks prerequisites only; normal server start provisions optional models.')
|
|
185
198
|
console.log('')
|
|
186
199
|
process.exit(0)
|
|
187
200
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.15.0",
|
|
4
4
|
"description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node bin/cli.cjs",
|
|
12
12
|
"start:server": "node --import tsx/esm server/index.ts",
|
|
13
|
-
"test": "vitest run",
|
|
13
|
+
"test": "vitest run --maxWorkers=1",
|
|
14
14
|
"typecheck": "tsc --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
package/server/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
stopCodexModelCatalogRefresh,
|
|
45
45
|
} from './lib/codex-model-catalog.js'
|
|
46
46
|
import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
|
|
47
|
+
import { startLocalTtsServer, stopLocalTtsServer } from './lib/tts-local.js'
|
|
47
48
|
import { initSileroVAD } from './lib/vad-silero.js'
|
|
48
49
|
import { initSessionCache } from './lib/session-cache-writer.js'
|
|
49
50
|
import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
|
|
@@ -266,6 +267,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
266
267
|
try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
|
|
267
268
|
stopCodexModelCatalogRefresh()
|
|
268
269
|
stopWhisperServer()
|
|
270
|
+
stopLocalTtsServer()
|
|
269
271
|
clearTimeout(forceExit)
|
|
270
272
|
process.exit(0)
|
|
271
273
|
}
|
|
@@ -385,6 +387,7 @@ listenRequiredServers(listeners).then(() => {
|
|
|
385
387
|
}
|
|
386
388
|
// Start local whisper-server (model stays in RAM for ~50ms transcription)
|
|
387
389
|
startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
|
|
390
|
+
startLocalTtsServer().catch(err => console.error('[startup] Local TTS server error:', err))
|
|
388
391
|
// Initialize speaker embeddings (voiceprint-based diarization) — fails soft if model absent
|
|
389
392
|
const embeddingOk = initSpeakerEmbeddings()
|
|
390
393
|
console.log(`[startup] Speaker embeddings: ${embeddingOk ? 'active' : 'disabled (model not found)'}`)
|
package/server/lib/tts-cache.ts
CHANGED
|
@@ -67,6 +67,10 @@ interface SessionEntry {
|
|
|
67
67
|
text: string
|
|
68
68
|
voice: string
|
|
69
69
|
format: string
|
|
70
|
+
/** One-shot OpenAI opt-in for this session (play cold-miss must honor it). */
|
|
71
|
+
preferOpenAI?: boolean
|
|
72
|
+
/** Settings forced Local/Kokoro — do not auto-escape to OpenAI on play miss. */
|
|
73
|
+
forceLocal?: boolean
|
|
70
74
|
expiresAt: number
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -114,8 +118,18 @@ let totalDiskBytes = 0 // on-disk bytes (sum of all sidecar sizeBytes)
|
|
|
114
118
|
* a different voice for the same text correctly misses (and gets its own
|
|
115
119
|
* entry). Hash is sha256 truncated to 16 hex chars — collision probability is
|
|
116
120
|
* ~negligible for our scale. */
|
|
117
|
-
export function hashKey(
|
|
118
|
-
|
|
121
|
+
export function hashKey(
|
|
122
|
+
engine: string,
|
|
123
|
+
mappedVoice: string,
|
|
124
|
+
format: string,
|
|
125
|
+
text: string,
|
|
126
|
+
instructions?: string,
|
|
127
|
+
): string {
|
|
128
|
+
let material = `${engine}\0${mappedVoice}\0${format}\0${text}`
|
|
129
|
+
if (engine === 'openai' && instructions && instructions.trim().length > 0) {
|
|
130
|
+
material += `\0${instructions.trim()}`
|
|
131
|
+
}
|
|
132
|
+
return createHash('sha256').update(material).digest('hex').slice(0, 16)
|
|
119
133
|
}
|
|
120
134
|
|
|
121
135
|
function ensureDiskDir(): void {
|
|
@@ -512,6 +526,18 @@ export function peekSession(uuid: string): SessionEntry | null {
|
|
|
512
526
|
return s
|
|
513
527
|
}
|
|
514
528
|
|
|
529
|
+
/** Rebind a live session to a new cache hash (openai -> local fallback). */
|
|
530
|
+
export function rebindSessionHash(uuid: string, hash: string): boolean {
|
|
531
|
+
const s = sessions.get(uuid)
|
|
532
|
+
if (!s) return false
|
|
533
|
+
if (s.expiresAt < Date.now()) {
|
|
534
|
+
sessions.delete(uuid)
|
|
535
|
+
return false
|
|
536
|
+
}
|
|
537
|
+
s.hash = hash
|
|
538
|
+
return true
|
|
539
|
+
}
|
|
540
|
+
|
|
515
541
|
/** Strict one-shot lookup — kept for callers that want to invalidate the
|
|
516
542
|
* session immediately on first read. Not used by /play in v5.9.4+. */
|
|
517
543
|
export function consumeSession(uuid: string): SessionEntry | null {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Local TTS engine mode + OpenAI voice → Kokoro preset map.
|
|
2
|
+
// Default product mode: local_first (Kokoro). OpenAI is opt-in escape hatch.
|
|
3
|
+
// Modes: openai | openai_primary | local_first | local
|
|
4
|
+
// Bare "auto" → local_first.
|
|
5
|
+
// Per-request engine: 'local' | 'openai' forces that backend (Settings picker).
|
|
6
|
+
|
|
7
|
+
export type TtsEngineMode = 'openai' | 'openai_primary' | 'local_first' | 'local'
|
|
8
|
+
export type TtsBackend = 'openai' | 'local'
|
|
9
|
+
export type TtsEnginePreference = 'local' | 'openai'
|
|
10
|
+
|
|
11
|
+
/** Cache/engine tag written into hashKey — distinguishes OpenAI audio from Kokoro. */
|
|
12
|
+
export type TtsEngineTag = 'openai' | 'kokoro'
|
|
13
|
+
|
|
14
|
+
export const OPENAI_VOICE_OPTIONS = [
|
|
15
|
+
{ id: 'echo', label: 'Echo (default)' },
|
|
16
|
+
{ id: 'alloy', label: 'Alloy' },
|
|
17
|
+
{ id: 'nova', label: 'Nova' },
|
|
18
|
+
{ id: 'sage', label: 'Sage' },
|
|
19
|
+
{ id: 'onyx', label: 'Onyx' },
|
|
20
|
+
{ id: 'shimmer', label: 'Shimmer' },
|
|
21
|
+
{ id: 'fable', label: 'Fable' },
|
|
22
|
+
{ id: 'ash', label: 'Ash' },
|
|
23
|
+
{ id: 'coral', label: 'Coral' },
|
|
24
|
+
] as const
|
|
25
|
+
|
|
26
|
+
/** American English Kokoro presets verified on disk (mlx-community/Kokoro-82M-bf16). */
|
|
27
|
+
export const KOKORO_VOICE_OPTIONS = [
|
|
28
|
+
{ id: 'am_echo', label: 'Echo (am_echo) · default' },
|
|
29
|
+
{ id: 'am_michael', label: 'Michael (am_michael)' },
|
|
30
|
+
{ id: 'am_fenrir', label: 'Fenrir (am_fenrir)' },
|
|
31
|
+
{ id: 'am_puck', label: 'Puck (am_puck)' },
|
|
32
|
+
{ id: 'am_onyx', label: 'Onyx (am_onyx)' },
|
|
33
|
+
{ id: 'am_eric', label: 'Eric (am_eric)' },
|
|
34
|
+
{ id: 'am_liam', label: 'Liam (am_liam)' },
|
|
35
|
+
{ id: 'am_adam', label: 'Adam (am_adam)' },
|
|
36
|
+
{ id: 'am_santa', label: 'Santa (am_santa)' },
|
|
37
|
+
{ id: 'af_heart', label: 'Heart (af_heart)' },
|
|
38
|
+
{ id: 'af_bella', label: 'Bella (af_bella)' },
|
|
39
|
+
{ id: 'af_nova', label: 'Nova (af_nova)' },
|
|
40
|
+
{ id: 'af_sarah', label: 'Sarah (af_sarah)' },
|
|
41
|
+
{ id: 'af_sky', label: 'Sky (af_sky)' },
|
|
42
|
+
{ id: 'af_nicole', label: 'Nicole (af_nicole)' },
|
|
43
|
+
{ id: 'af_jessica', label: 'Jessica (af_jessica)' },
|
|
44
|
+
{ id: 'af_alloy', label: 'Alloy (af_alloy)' },
|
|
45
|
+
{ id: 'af_aoede', label: 'Aoede (af_aoede)' },
|
|
46
|
+
{ id: 'af_kore', label: 'Kore (af_kore)' },
|
|
47
|
+
{ id: 'af_river', label: 'River (af_river)' },
|
|
48
|
+
] as const
|
|
49
|
+
|
|
50
|
+
export const KOKORO_EN_US_VOICES = KOKORO_VOICE_OPTIONS.map((v) => v.id)
|
|
51
|
+
|
|
52
|
+
// OpenAI id → Kokoro when Settings still sends an OpenAI id on the local path.
|
|
53
|
+
const VOICE_MAP: Record<string, string> = {
|
|
54
|
+
echo: 'am_echo',
|
|
55
|
+
alloy: 'af_heart',
|
|
56
|
+
fable: 'af_bella',
|
|
57
|
+
onyx: 'am_onyx',
|
|
58
|
+
nova: 'af_nova',
|
|
59
|
+
shimmer: 'af_sky',
|
|
60
|
+
ash: 'am_fenrir',
|
|
61
|
+
sage: 'af_sarah',
|
|
62
|
+
coral: 'af_jessica',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const OPENAI_VOICE_IDS = new Set(OPENAI_VOICE_OPTIONS.map((v) => v.id))
|
|
66
|
+
const KOKORO_VOICE_IDS = new Set(KOKORO_EN_US_VOICES)
|
|
67
|
+
|
|
68
|
+
export function isOpenAIVoiceId(voice: string): boolean {
|
|
69
|
+
return OPENAI_VOICE_IDS.has(voice as typeof OPENAI_VOICE_OPTIONS[number]['id'])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Kokoro / Misaki voice file ids look like am_echo, af_heart, bm_george. */
|
|
73
|
+
export function isKokoroVoiceId(voice: string): boolean {
|
|
74
|
+
return /^[a-z]{2}_[a-z0-9]+$/i.test(voice)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getTtsEngineMode(): TtsEngineMode {
|
|
78
|
+
const raw = (process.env.COS_TTS_ENGINE || 'local_first').trim().toLowerCase()
|
|
79
|
+
if (raw === 'auto') return 'local_first'
|
|
80
|
+
if (raw === 'openai' || raw === 'openai_primary' || raw === 'local_first' || raw === 'local') {
|
|
81
|
+
return raw
|
|
82
|
+
}
|
|
83
|
+
console.warn(`[tts-engine] unknown COS_TTS_ENGINE=${raw}; using local_first`)
|
|
84
|
+
return 'local_first'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resolve which Kokoro preset to synthesize for a local request. */
|
|
88
|
+
export function resolveLocalVoice(requested: string): string {
|
|
89
|
+
const v = (requested || '').trim()
|
|
90
|
+
if (v && isKokoroVoiceId(v)) {
|
|
91
|
+
const lower = v.toLowerCase()
|
|
92
|
+
// Accept any on-disk-shaped id; known list is the Settings catalog.
|
|
93
|
+
return lower
|
|
94
|
+
}
|
|
95
|
+
// Env pin only when mapping legacy OpenAI ids (not when client sent Kokoro).
|
|
96
|
+
const pinned = (process.env.COS_TTS_KOKORO_VOICE || '').trim()
|
|
97
|
+
if (pinned && isKokoroVoiceId(pinned)) return pinned.toLowerCase()
|
|
98
|
+
return VOICE_MAP[v] ?? VOICE_MAP.echo
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @deprecated use resolveLocalVoice — kept for tests / call sites. */
|
|
102
|
+
export function mapOpenAIVoiceToLocal(openaiVoice: string): string {
|
|
103
|
+
return resolveLocalVoice(openaiVoice)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function localEngineTag(): TtsEngineTag {
|
|
107
|
+
const pinned = (process.env.COS_TTS_LOCAL_ENGINE || 'kokoro').trim().toLowerCase()
|
|
108
|
+
return pinned === 'kokoro' ? 'kokoro' : 'kokoro'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface TtsRouteDecision {
|
|
112
|
+
backend: TtsBackend
|
|
113
|
+
engineTag: TtsEngineTag
|
|
114
|
+
/** Voice id sent to the chosen backend. */
|
|
115
|
+
backendVoice: string
|
|
116
|
+
/** Voice id from the client (OpenAI or Kokoro). */
|
|
117
|
+
openaiVoice: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pick initial backend for a request.
|
|
122
|
+
*
|
|
123
|
+
* enginePreference:
|
|
124
|
+
* - 'openai' → cloud (Settings “OpenAI”)
|
|
125
|
+
* - 'local' → Kokoro (Settings “Local”)
|
|
126
|
+
* - omitted → daemon COS_TTS_ENGINE (local_first default)
|
|
127
|
+
*/
|
|
128
|
+
export function decideInitialBackend(opts: {
|
|
129
|
+
openaiVoice: string
|
|
130
|
+
openaiKeyPresent: boolean
|
|
131
|
+
openaiBudgetOk: boolean
|
|
132
|
+
localReady: boolean
|
|
133
|
+
preferOpenAI?: boolean
|
|
134
|
+
enginePreference?: TtsEnginePreference | null
|
|
135
|
+
}): TtsRouteDecision {
|
|
136
|
+
const requested = opts.openaiVoice
|
|
137
|
+
const localVoice = resolveLocalVoice(requested)
|
|
138
|
+
const openaiVoice = isOpenAIVoiceId(requested) ? requested : 'echo'
|
|
139
|
+
const local: TtsRouteDecision = {
|
|
140
|
+
backend: 'local',
|
|
141
|
+
engineTag: localEngineTag(),
|
|
142
|
+
backendVoice: localVoice,
|
|
143
|
+
openaiVoice: requested,
|
|
144
|
+
}
|
|
145
|
+
const openai: TtsRouteDecision = {
|
|
146
|
+
backend: 'openai',
|
|
147
|
+
engineTag: 'openai',
|
|
148
|
+
backendVoice: openaiVoice,
|
|
149
|
+
openaiVoice: requested,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const preferOpenAI =
|
|
153
|
+
opts.preferOpenAI === true || opts.enginePreference === 'openai'
|
|
154
|
+
const forceLocal = opts.enginePreference === 'local'
|
|
155
|
+
|
|
156
|
+
if (forceLocal) {
|
|
157
|
+
if (!opts.localReady) {
|
|
158
|
+
throw new Error('Local TTS selected but Kokoro sidecar is not ready')
|
|
159
|
+
}
|
|
160
|
+
return local
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (preferOpenAI) {
|
|
164
|
+
if (opts.openaiKeyPresent && opts.openaiBudgetOk) return openai
|
|
165
|
+
if (opts.localReady) {
|
|
166
|
+
console.warn('[tts-engine] OpenAI requested but unavailable; using local')
|
|
167
|
+
return local
|
|
168
|
+
}
|
|
169
|
+
throw new Error('OpenAI TTS requested but key/budget unavailable and local TTS not ready')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Legacy: bare Kokoro voice id implies local even without enginePreference.
|
|
173
|
+
if (isKokoroVoiceId(requested) && opts.localReady) return local
|
|
174
|
+
|
|
175
|
+
const mode = getTtsEngineMode()
|
|
176
|
+
|
|
177
|
+
if (mode === 'openai') {
|
|
178
|
+
if (!opts.openaiKeyPresent) {
|
|
179
|
+
throw new Error('COS_TTS_ENGINE=openai but OPENAI_API_KEY is missing')
|
|
180
|
+
}
|
|
181
|
+
if (!opts.openaiBudgetOk) {
|
|
182
|
+
throw new Error('COS_TTS_ENGINE=openai but daily OpenAI TTS budget is exhausted')
|
|
183
|
+
}
|
|
184
|
+
return openai
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (mode === 'local') {
|
|
188
|
+
if (!opts.localReady) {
|
|
189
|
+
throw new Error('COS_TTS_ENGINE=local but local TTS sidecar is not ready')
|
|
190
|
+
}
|
|
191
|
+
return local
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (mode === 'local_first') {
|
|
195
|
+
if (opts.localReady) return local
|
|
196
|
+
if (opts.openaiKeyPresent && opts.openaiBudgetOk) return openai
|
|
197
|
+
throw new Error('local TTS unavailable and OpenAI key/budget not usable')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// openai_primary
|
|
201
|
+
if (opts.openaiKeyPresent && opts.openaiBudgetOk) return openai
|
|
202
|
+
if (opts.localReady) return local
|
|
203
|
+
if (!opts.openaiKeyPresent) {
|
|
204
|
+
throw new Error('OpenAI TTS key missing and local TTS sidecar is not ready')
|
|
205
|
+
}
|
|
206
|
+
throw new Error('OpenAI TTS budget exhausted and local TTS sidecar is not ready')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** After an OpenAI failure, can we fall back to local under this mode? */
|
|
210
|
+
export function canFallbackToLocal(
|
|
211
|
+
mode: TtsEngineMode,
|
|
212
|
+
localReady: boolean,
|
|
213
|
+
preferOpenAI = false,
|
|
214
|
+
): boolean {
|
|
215
|
+
if (!localReady) return false
|
|
216
|
+
if (preferOpenAI) return true
|
|
217
|
+
return mode === 'openai_primary' || mode === 'local_first'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** After a local failure, can we fall back to OpenAI under this mode? */
|
|
221
|
+
export function canFallbackToOpenAI(
|
|
222
|
+
mode: TtsEngineMode,
|
|
223
|
+
openaiKeyPresent: boolean,
|
|
224
|
+
openaiBudgetOk: boolean,
|
|
225
|
+
forceLocal = false,
|
|
226
|
+
): boolean {
|
|
227
|
+
if (forceLocal) return false
|
|
228
|
+
return openaiKeyPresent && openaiBudgetOk && mode === 'local_first'
|
|
229
|
+
}
|
|
230
|
+
|