Skip to main content
Glama

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

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):

DATA_DIR=/tmp/voice/data ../.venv/bin/python -m voice_api.seed --name mybot --scopes transcribe,speak

Smoke 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 -v

MCP 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_proxy

Tools: 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 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.