Text-to-Speech (synaplan-tts)

Voice output in Synaplan is a companion service: synaplan-tts, a self-hosted Piper HTTP API. The published image is ghcr.io/metadist/synaplan-tts. It starts with the standard stack — make up brings spoken answers with no extra step.

Synaplan runs fully without it. The speaker control appears when the service answers. Speech input (Whisper) already ships with the main platform and does not need this container.

Bundled voices: English, German, Spanish, French, Turkish — baked into the image, no first-run download.


Why keep TTS independent?

Most installs never need spoken answers. Piper models are large (~60 MB each) and synthesis is CPU-heavy. A separate image means:

  • The core Synaplan stack stays small. Chat, RAG, and widgets work without pulling voice models.
  • You can run Piper on another machine (a LAN box, a GPU host) and point several Synaplan nodes at one speaker.
  • Voice models update independently of the PHP/Vue app.
  • Operators who want air-gapped / no-cloud speech keep audio on infrastructure they control, without sending text to ElevenLabs, Gemini, or similar.

If you prefer a cloud voice instead, set ELEVENLABS_API_KEY or pick a catalog TTS model (Gemini, Mistral Voxtral, xAI). Those stay inside the main app and do not use this service.


Install

Option A — with the stack (default)

The standard compose files start a tts service out of the box — no profile, no extra step. The backend default SYNAPLAN_TTS_URL=http://tts:10200 points at that service. Production (deploy/compose.yaml) ships the same service without a host port; the development stack additionally publishes it on 127.0.0.1:10200 for convenience. To hide spoken answers, stop the container:

docker compose stop tts

Verify the stack service from inside the backend container (works on both stacks):

docker compose exec -T backend php -r 'echo file_get_contents("http://tts:10200/health"), "\n";'

(On the development stack curl http://127.0.0.1:10200/health on the host works too.)

The minimal stack keeps TTS opt-in:

docker compose -f docker-compose-minimal.yml --profile tts up -d

Option B — standalone (this host or another)

docker run -d --name synaplan-tts \
  -p 127.0.0.1:10200:10200 \
  ghcr.io/metadist/synaplan-tts:latest

Or clone and compose:

git clone https://github.com/metadist/synaplan-tts.git
cd synaplan-tts
docker compose up -d

If TTS runs on another host, set the URL in backend/.env and restart backend + worker:

SYNAPLAN_TTS_URL=http://10.0.1.10:10200

A standalone container on the same laptop is reached as http://host.docker.internal:10200 from inside the backend container.

Verify on the host that runs the standalone container:

curl http://127.0.0.1:10200/health

You should see "voices_loaded": 5.

curl "http://127.0.0.1:10200/api/tts?text=Hallo+Welt&language=de" -o test_de.wav

Full API: synaplan-tts on GitHub.


Frontend language selects the voice

There is no separate voice picker. The spoken voice follows the conversation language:

  1. The chat UI sends the active frontend locale (en, de, es, fr, tr — Settings → language).
  2. If the backend detects a different reply language, that short code wins.
  3. PiperProvider maps the code to a baked voice:
UI / reply language Voice
English en_US-lessac-medium (lessac)
German de_DE-kerstin-low (kerstin)
Spanish es_ES-davefx-medium (davefx)
French fr_FR-siwis-medium (siwis)
Turkish tr_TR-dfki-medium (dfki)

An explicit voice= on /api/v1/tts/stream still overrides both. Unmapped languages fall back to the user's configured TEXT2SOUND model, then to English.


Adding more voices

The image must not be remounted over /voices — that hides the five built-in models. Extra voices go in EXTRA_VOICES_DIR (/voices-extra).

  1. Download a pair from Piper Voices: <name>.onnx and <name>.onnx.json.
  2. Put both files in the extra-voices volume (standalone repo: ./voices/; Synaplan profile: the tts_extra_voices volume).
  3. Restart the TTS container.
  4. GET /api/voices lists every loaded model.

Helper in the synaplan-tts repo:

./download-voices.sh ru fa
docker compose restart piper

Russian and Persian are mapped in Synaplan already; they only speak after you add those files.


Notes for self-hosters

  • Bind to a specific interface (default 127.0.0.1). Never 0.0.0.0 on a public NIC.
  • Firewall port 10200/tcp so only Synaplan nodes can reach it.
  • Synaplan auto-enables voice output when /health returns ok — nothing to toggle in the app.
  • See Quickstart & Self-Hosting, Pitfall 3, for how this sits next to Qdrant and local models.