voice-mcp
README.md
# voice-service
A self-hosted **Voice API** (speech-to-text + text-to-speech) with real auth and
a small admin website, plus a thin **MCP stdio proxy** that lets MCP clients
(bots, tools) call the API as native tools.
Monorepo, two deployables:
```
voice-service/
├── voice-api/ # FastAPI server: Voice REST API + admin web (dockerized, runs on the VPS)
├── mcp-proxy/ # MCP stdio proxy (client shim; pip-installable / vendored into bot images)
├── docs/ # design spec
├── docker-compose.yml
└── README.md
```
## Architecture
```
browser (admin) ──session cookie──▶ Admin Web ┐
├─(same FastAPI app)─▶ Voice API ─▶ SQLite + audio cache + Whisper
bots / tools ──stdio──▶ MCP proxy ──Bearer─────┘
```
One container runs the FastAPI app serving **both** the Voice API and the admin
web against one SQLite DB. The MCP proxy is a separate artifact that runs locally
next to each client and translates tool calls into authenticated REST calls.
## Voice API
All API endpoints except `/audio`, `/healthz` require `Authorization: Bearer <api-key>`.
| Method | Path | Scope | Body / notes |
| --- | --- | --- | --- |
| POST | `/transcribe` | `transcribe` | `{audio_base64, language?}` → `{text, language}` (Whisper, lazy-loaded, threadpool) |
| POST | `/speak` | `speak` | `{text, lang?, engine?, voice?, style?}` → `{url, cached, engine, voice, format}` |
| GET | `/voices` | any key | Gemini voices + current default |
| GET | `/audio/<sha>.ogg` | none | serves a cached clip (opaque hashed name) |
| GET | `/healthz` | none | liveness + engine/model info |
**TTS engines.** `gTTS` is the free default (one voice per language, no key).
**Gemini TTS is optional** — enable it by pasting a Gemini key in the admin
settings, then choose the engine mode: `gtts`, `gemini_primary` (Gemini first,
gTTS fallback), or `gemini_fallback` (gTTS first, Gemini on failure). `voice` and
`style` apply to Gemini only (30 prebuilt voices + natural-language style); gTTS
ignores them. All output is transcoded by ffmpeg to **Ogg/Opus** so it renders as
a Telegram voice bubble. `/speak` returns a URL, never base64.
**Audio cache.** Content-addressed: `sha256(text + lang + engine + voice + style)`.
A cache hit returns instantly with no engine/ffmpeg work. gTTS clips are keyed
with empty voice/style so they are shared across requests.
## Admin website
Session-cookie login (Starlette `SessionMiddleware`), CSRF on all POST forms,
login rate-limit, forced password change on first login.
- First boot seeds an `admin` user with a **random password**, written to
`/data/admin-password.txt` (0600) and logged once; `must_change=1`.
- `/keys` — create (full key shown once), enable/disable, delete API keys per bot.
- `/settings` — Gemini key (write-only), engine mode, Whisper model, default
voice + style, with a **preview** button.
- `/dashboard` — per-key usage (requests, STT seconds, TTS chars) over 7 / 30 days.
## Run locally
```bash
cd voice-service
python3.12 -m venv .venv && .venv/bin/pip install -r voice-api/requirements.txt
# ffmpeg must be on PATH (brew install ffmpeg / apt install ffmpeg)
mkdir -p /tmp/voice/{data,models,audio}
cd voice-api
DATA_DIR=/tmp/voice/data MODELS_DIR=/tmp/voice/models AUDIO_DIR=/tmp/voice/audio \
WHISPER_MODEL=tiny PUBLIC_BASE_URL=http://localhost:8770 PORT=8770 HOST=127.0.0.1 \
VOICE_COOKIE_SECURE=0 \
../.venv/bin/python -m voice_api
```
The first-boot admin password is printed to the log and written to
`/tmp/voice/data/admin-password.txt`. Open http://localhost:8770/login, change
the password, then create an API key under **API keys** (or use the CLI):
```bash
DATA_DIR=/tmp/voice/data ../.venv/bin/python -m voice_api.seed --name mybot --scopes transcribe,speak
```
Smoke test:
```bash
KEY=vsk_... # the key you created
curl -s -X POST localhost:8770/speak -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"text":"xin chào","lang":"vi"}'
```
Run the tests:
```bash
.venv/bin/python -m pytest voice-api/tests -v
```
## MCP proxy
```bash
pip install ./mcp-proxy # or vendor voice_mcp_proxy into the bot image
# register with a client (env carries the URL + per-bot key):
VOICE_API_URL=https://voice.veasy.vn VOICE_API_KEY=vsk_... \
claude mcp add --transport stdio voice -- python -m voice_mcp_proxy
```
Tools: `transcribe`, `speak(text, lang?, engine?, voice?, style?)`, `list_voices`,
`voice_info`.
## Deploy (VPS behind a reverse proxy)
```bash
cp .env.example .env # set PUBLIC_BASE_URL=https://voice.veasy.vn, WHISPER_MODEL, etc.
docker compose up -d --build
docker compose logs voice-api | grep 'First-boot admin' # grab the password once
```
Point a reverse proxy (nginx/Caddy/Traefik) with HTTPS at `127.0.0.1:8770`. Keep
`VOICE_COOKIE_SECURE=1` in production. Gemini key + engine mode are configured in
the admin web (stored in the DB), not in env.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues