@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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Bootstrap pinned Cos Local TTS venv under ~/.local/share/cos-tts-models/.venv
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
MODEL_DIR="${COS_TTS_MODEL_DIR:-$HOME/.local/share/cos-tts-models}"
|
|
6
|
+
VENV="$MODEL_DIR/.venv"
|
|
7
|
+
PY="${COS_TTS_BOOTSTRAP_PYTHON:-}"
|
|
8
|
+
if [[ -z "$PY" ]]; then
|
|
9
|
+
if command -v python3.13 >/dev/null 2>&1; then PY="$(command -v python3.13)"
|
|
10
|
+
elif command -v python3.12 >/dev/null 2>&1; then PY="$(command -v python3.12)"
|
|
11
|
+
elif command -v python3.11 >/dev/null 2>&1; then PY="$(command -v python3.11)"
|
|
12
|
+
elif command -v python3 >/dev/null 2>&1; then PY="$(command -v python3)"
|
|
13
|
+
else
|
|
14
|
+
echo "[cos-tts] Python 3.11-3.13 is required for local Kokoro" >&2
|
|
15
|
+
exit 2
|
|
16
|
+
fi
|
|
17
|
+
fi
|
|
18
|
+
PY_MINOR="$($PY -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
|
19
|
+
case "$PY_MINOR" in
|
|
20
|
+
3.11|3.12|3.13) ;;
|
|
21
|
+
*)
|
|
22
|
+
echo "[cos-tts] unsupported Python $PY_MINOR; install Python 3.11, 3.12, or 3.13" >&2
|
|
23
|
+
exit 2
|
|
24
|
+
;;
|
|
25
|
+
esac
|
|
26
|
+
mkdir -p "$MODEL_DIR"
|
|
27
|
+
if [[ ! -x "$VENV/bin/python" ]]; then
|
|
28
|
+
echo "[cos-tts] creating venv at $VENV with $PY"
|
|
29
|
+
"$PY" -m venv "$VENV"
|
|
30
|
+
fi
|
|
31
|
+
"$VENV/bin/pip" install --disable-pip-version-check 'pip==25.1.1' 'wheel==0.45.1'
|
|
32
|
+
"$VENV/bin/pip" install -r "$ROOT/requirements.txt"
|
|
33
|
+
echo "[cos-tts] bootstrap complete: $VENV/bin/python"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# COS Glasses Local TTS sidecar — Kokoro-82M MLX.
|
|
2
|
+
# Exact versions reproduce the release-tested Apple silicon environment.
|
|
3
|
+
mlx-audio==0.4.6
|
|
4
|
+
misaki[en]==0.9.4
|
|
5
|
+
fastapi==0.140.0
|
|
6
|
+
uvicorn[standard]==0.51.0
|
|
7
|
+
soundfile==0.14.0
|
|
8
|
+
numpy==2.4.6
|
|
9
|
+
python-multipart==0.0.32
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""COS Glasses local TTS sidecar — Kokoro-82M MLX.
|
|
3
|
+
|
|
4
|
+
OpenAI-shaped POST /v1/audio/speech + GET /health.
|
|
5
|
+
Owned by cos-glasses-server (port 8179).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterator
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import soundfile as sf
|
|
21
|
+
import uvicorn
|
|
22
|
+
from fastapi import FastAPI, Header, HTTPException
|
|
23
|
+
from fastapi.responses import Response
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
os.environ.setdefault("ESPEAK_DATA_PATH", "/opt/homebrew/share/espeak-ng-data")
|
|
27
|
+
os.environ.setdefault("PHONEMIZER_ESPEAK_PATH", "/opt/homebrew/bin/espeak-ng")
|
|
28
|
+
|
|
29
|
+
ENGINE = "kokoro"
|
|
30
|
+
PROTOCOL = "cos-tts-v1"
|
|
31
|
+
AUTH_TOKEN = os.environ.get("COS_TTS_AUTH_TOKEN", "")
|
|
32
|
+
MODEL_ID = os.environ.get("COS_TTS_KOKORO_MODEL", "mlx-community/Kokoro-82M-bf16")
|
|
33
|
+
DEFAULT_VOICE = os.environ.get("COS_TTS_KOKORO_VOICE", "am_echo")
|
|
34
|
+
SAMPLE_RATE = 24_000
|
|
35
|
+
|
|
36
|
+
_model = None
|
|
37
|
+
_lock = threading.Lock()
|
|
38
|
+
_ready = False
|
|
39
|
+
_load_error: str | None = None
|
|
40
|
+
_loaded_at: float | None = None
|
|
41
|
+
|
|
42
|
+
app = FastAPI(title="COS Local TTS", version="0.1.0")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SpeechRequest(BaseModel):
|
|
46
|
+
model: str = "tts-1"
|
|
47
|
+
input: str = Field(..., min_length=1)
|
|
48
|
+
voice: str = DEFAULT_VOICE
|
|
49
|
+
response_format: str = "mp3"
|
|
50
|
+
speed: float = 1.0
|
|
51
|
+
# OpenAI instructions — accepted and ignored (no error).
|
|
52
|
+
instructions: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _authorize(authorization: str | None) -> None:
|
|
56
|
+
if not AUTH_TOKEN or authorization != f"Bearer {AUTH_TOKEN}":
|
|
57
|
+
raise HTTPException(status_code=401, detail="unauthorized")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ensure_ffmpeg() -> None:
|
|
61
|
+
from shutil import which
|
|
62
|
+
|
|
63
|
+
if which("ffmpeg") is None:
|
|
64
|
+
raise RuntimeError("ffmpeg not found on PATH — required for non-wav formats")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ensure_espeak() -> None:
|
|
68
|
+
from shutil import which
|
|
69
|
+
|
|
70
|
+
if which("espeak-ng") is None and which("espeak") is None:
|
|
71
|
+
raise RuntimeError("espeak-ng not found — required for Kokoro G2P")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_model() -> None:
|
|
75
|
+
global _model, _ready, _load_error, _loaded_at
|
|
76
|
+
try:
|
|
77
|
+
_ensure_ffmpeg()
|
|
78
|
+
_ensure_espeak()
|
|
79
|
+
from mlx_audio.tts.utils import load_model as mlx_load
|
|
80
|
+
|
|
81
|
+
t0 = time.perf_counter()
|
|
82
|
+
_model = mlx_load(MODEL_ID)
|
|
83
|
+
# Warm preferred voice; fall back if missing.
|
|
84
|
+
voice = DEFAULT_VOICE
|
|
85
|
+
for candidate in (DEFAULT_VOICE, "am_echo", "am_michael", "af_heart"):
|
|
86
|
+
try:
|
|
87
|
+
list(_model.generate(text="Warmup.", voice=candidate, lang_code="a", speed=1.0))
|
|
88
|
+
voice = candidate
|
|
89
|
+
break
|
|
90
|
+
except Exception:
|
|
91
|
+
continue
|
|
92
|
+
os.environ["COS_TTS_KOKORO_VOICE"] = voice
|
|
93
|
+
_loaded_at = time.time()
|
|
94
|
+
_ready = True
|
|
95
|
+
_load_error = None
|
|
96
|
+
print(f"[cos-tts] Kokoro ready voice={voice} model={MODEL_ID} cold={time.perf_counter() - t0:.2f}s")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
_ready = False
|
|
99
|
+
_load_error = str(e)
|
|
100
|
+
print(f"[cos-tts] load failed: {e}")
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def synthesize(text: str, voice: str, speed: float) -> np.ndarray:
|
|
105
|
+
if _model is None:
|
|
106
|
+
raise RuntimeError("model not loaded")
|
|
107
|
+
requested = voice or DEFAULT_VOICE
|
|
108
|
+
voices_to_try = [requested]
|
|
109
|
+
fallback = os.environ.get("COS_TTS_KOKORO_VOICE", DEFAULT_VOICE)
|
|
110
|
+
if fallback not in voices_to_try:
|
|
111
|
+
voices_to_try.append(fallback)
|
|
112
|
+
if "am_echo" not in voices_to_try:
|
|
113
|
+
voices_to_try.append("am_echo")
|
|
114
|
+
|
|
115
|
+
last_err: Exception | None = None
|
|
116
|
+
for candidate in voices_to_try:
|
|
117
|
+
pieces: list[np.ndarray] = []
|
|
118
|
+
try:
|
|
119
|
+
with _lock:
|
|
120
|
+
for result in _model.generate(
|
|
121
|
+
text=text,
|
|
122
|
+
voice=candidate,
|
|
123
|
+
lang_code="a",
|
|
124
|
+
speed=float(speed) if speed else 1.0,
|
|
125
|
+
):
|
|
126
|
+
pieces.append(np.array(result.audio, dtype=np.float32))
|
|
127
|
+
if not pieces:
|
|
128
|
+
return np.zeros(1, dtype=np.float32)
|
|
129
|
+
if candidate != requested:
|
|
130
|
+
print(f"[cos-tts] voice {requested!r} missing; used {candidate!r}")
|
|
131
|
+
return np.concatenate(pieces)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
last_err = e
|
|
134
|
+
continue
|
|
135
|
+
raise RuntimeError(f"Kokoro voice failed for {requested!r}: {last_err}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def encode_audio(audio: np.ndarray, fmt: str) -> tuple[bytes, str]:
|
|
139
|
+
fmt = (fmt or "mp3").lower()
|
|
140
|
+
mime = {
|
|
141
|
+
"mp3": "audio/mpeg",
|
|
142
|
+
"wav": "audio/wav",
|
|
143
|
+
"flac": "audio/flac",
|
|
144
|
+
"opus": "audio/ogg",
|
|
145
|
+
"aac": "audio/aac",
|
|
146
|
+
"pcm": "audio/pcm",
|
|
147
|
+
}.get(fmt)
|
|
148
|
+
if mime is None:
|
|
149
|
+
raise HTTPException(status_code=400, detail=f"unsupported response_format: {fmt}")
|
|
150
|
+
|
|
151
|
+
if fmt == "pcm":
|
|
152
|
+
pcm = (np.clip(audio, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes()
|
|
153
|
+
return pcm, mime
|
|
154
|
+
|
|
155
|
+
with tempfile.TemporaryDirectory(prefix="cos-tts-") as tmp:
|
|
156
|
+
wav_path = Path(tmp) / "out.wav"
|
|
157
|
+
sf.write(str(wav_path), audio, SAMPLE_RATE)
|
|
158
|
+
if fmt == "wav":
|
|
159
|
+
return wav_path.read_bytes(), mime
|
|
160
|
+
|
|
161
|
+
out_path = Path(tmp) / f"out.{fmt}"
|
|
162
|
+
cmd = ["ffmpeg", "-y", "-i", str(wav_path)]
|
|
163
|
+
if fmt == "mp3":
|
|
164
|
+
cmd += ["-codec:a", "libmp3lame", "-q:a", "4"]
|
|
165
|
+
elif fmt == "flac":
|
|
166
|
+
cmd += ["-codec:a", "flac"]
|
|
167
|
+
elif fmt == "opus":
|
|
168
|
+
cmd += ["-codec:a", "libopus"]
|
|
169
|
+
elif fmt == "aac":
|
|
170
|
+
cmd += ["-codec:a", "aac"]
|
|
171
|
+
cmd.append(str(out_path))
|
|
172
|
+
proc = subprocess.run(cmd, capture_output=True)
|
|
173
|
+
if proc.returncode != 0:
|
|
174
|
+
raise HTTPException(
|
|
175
|
+
status_code=500,
|
|
176
|
+
detail=f"ffmpeg encode failed: {proc.stderr.decode()[:300]}",
|
|
177
|
+
)
|
|
178
|
+
return out_path.read_bytes(), mime
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.on_event("startup")
|
|
182
|
+
def _startup() -> None:
|
|
183
|
+
load_model()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.get("/health")
|
|
187
|
+
def health(authorization: str | None = Header(default=None)):
|
|
188
|
+
_authorize(authorization)
|
|
189
|
+
return {
|
|
190
|
+
"ready": _ready,
|
|
191
|
+
"protocol": PROTOCOL,
|
|
192
|
+
"engine": ENGINE,
|
|
193
|
+
"model": MODEL_ID,
|
|
194
|
+
"voice": os.environ.get("COS_TTS_KOKORO_VOICE", DEFAULT_VOICE),
|
|
195
|
+
"ffmpeg": True,
|
|
196
|
+
"espeak": True,
|
|
197
|
+
"port": int(os.environ.get("COS_TTS_PORT", "8179")),
|
|
198
|
+
"loaded_at": _loaded_at,
|
|
199
|
+
"error": _load_error,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.post("/v1/audio/speech")
|
|
204
|
+
def speech(req: SpeechRequest, authorization: str | None = Header(default=None)):
|
|
205
|
+
_authorize(authorization)
|
|
206
|
+
# instructions intentionally ignored for local path
|
|
207
|
+
_ = req.instructions
|
|
208
|
+
if not _ready or _model is None:
|
|
209
|
+
raise HTTPException(status_code=503, detail=_load_error or "local TTS not ready")
|
|
210
|
+
text = req.input.strip()
|
|
211
|
+
if not text:
|
|
212
|
+
raise HTTPException(status_code=400, detail="input is required")
|
|
213
|
+
if len(text) > 4000:
|
|
214
|
+
text = text[:4000]
|
|
215
|
+
try:
|
|
216
|
+
audio = synthesize(text, req.voice, req.speed)
|
|
217
|
+
body, mime = encode_audio(audio, req.response_format)
|
|
218
|
+
except HTTPException:
|
|
219
|
+
raise
|
|
220
|
+
except Exception as e:
|
|
221
|
+
raise HTTPException(status_code=500, detail=str(e)[:300]) from e
|
|
222
|
+
return Response(content=body, media_type=mime)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main() -> None:
|
|
226
|
+
parser = argparse.ArgumentParser()
|
|
227
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
228
|
+
parser.add_argument("--port", type=int, default=8179)
|
|
229
|
+
args = parser.parse_args()
|
|
230
|
+
os.environ["COS_TTS_PORT"] = str(args.port)
|
|
231
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
main()
|