@gotcos/glasses-server 6.14.1 → 6.15.1

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.
@@ -0,0 +1,57 @@
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
+
9
+ # The pinned stack has one strict intersection: numpy 2.4.6 requires 3.11+ and
10
+ # misaki 0.9.4 requires <3.13. Select by the interpreter's real version instead
11
+ # of trusting its filename so a newer `python3` alias cannot poison bootstrap.
12
+ python_minor() {
13
+ "$1" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null
14
+ }
15
+
16
+ python_is_compatible() {
17
+ local minor
18
+ minor="$(python_minor "$1")" || return 1
19
+ [[ "$minor" == "3.11" || "$minor" == "3.12" ]]
20
+ }
21
+
22
+ if [[ -n "$PY" ]]; then
23
+ if [[ ! -x "$PY" ]] || ! python_is_compatible "$PY"; then
24
+ PY_MINOR="$(python_minor "$PY" 2>/dev/null || echo unknown)"
25
+ echo "[cos-tts] COS_TTS_BOOTSTRAP_PYTHON is Python $PY_MINOR; pinned Kokoro requires Python 3.11 or 3.12" >&2
26
+ exit 2
27
+ fi
28
+ else
29
+ for candidate in python3.12 python3.11 python3; do
30
+ candidate_path="$(command -v "$candidate" 2>/dev/null || true)"
31
+ if [[ -n "$candidate_path" ]] && python_is_compatible "$candidate_path"; then
32
+ PY="$candidate_path"
33
+ break
34
+ fi
35
+ done
36
+ if [[ -z "$PY" ]]; then
37
+ echo "[cos-tts] compatible Python not found; install Python 3.12 with: brew install python@3.12" >&2
38
+ exit 2
39
+ fi
40
+ fi
41
+
42
+ mkdir -p "$MODEL_DIR"
43
+ if [[ -x "$VENV/bin/python" ]] && ! python_is_compatible "$VENV/bin/python"; then
44
+ VENV_MINOR="$(python_minor "$VENV/bin/python" 2>/dev/null || echo unknown)"
45
+ echo "[cos-tts] rebuilding incompatible Python $VENV_MINOR venv"
46
+ rm -rf -- "$VENV"
47
+ elif [[ -d "$VENV" && ! -x "$VENV/bin/python" ]]; then
48
+ echo "[cos-tts] rebuilding incomplete venv"
49
+ rm -rf -- "$VENV"
50
+ fi
51
+ if [[ ! -x "$VENV/bin/python" ]]; then
52
+ echo "[cos-tts] creating venv at $VENV with $PY"
53
+ "$PY" -m venv "$VENV"
54
+ fi
55
+ "$VENV/bin/pip" install --disable-pip-version-check 'pip==25.1.1' 'wheel==0.45.1'
56
+ "$VENV/bin/pip" install -r "$ROOT/requirements.txt"
57
+ 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()