voice-mcp
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.mdArchitecture
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 |
|
|
|
POST |
|
|
|
GET |
| any key | Gemini voices + current default |
GET |
| none | serves a cached clip (opaque hashed name) |
GET |
| 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
adminuser 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
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_apiThe 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):
DATA_DIR=/tmp/voice/data ../.venv/bin/python -m voice_api.seed --name mybot --scopes transcribe,speakSmoke test:
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:
.venv/bin/python -m pytest voice-api/tests -vMCP proxy
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_proxyTools: transcribe, speak(text, lang?, engine?, voice?, style?), list_voices,
voice_info.
Deploy (VPS behind a reverse proxy)
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 oncePoint 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.