Read-aloud: natural neural voice via local Piper TTS

Replace the browser's robotic Web Speech API (espeak on Chromium) as the
primary read-aloud path with server-side Piper neural TTS, served by petal
and kept fully offline on millenia next to Ollama.

- internal/tts: proxy short passages to Piper, transcode WAV -> mp3/opus via
  ffmpeg, content-addressed disk cache (instant re-taps). Each Piper instance
  loads one voice, so language routes to its own endpoint:{voice}. Unknown
  language -> 404 so the client falls back to Web Speech. UTF-8-safe truncation
  for multibyte (Chinese) text.
- config: TTS_ENDPOINT / TTS_ENDPOINT_ZH / TTS_VOICE_EN / TTS_VOICE_ZH /
  TTS_CACHE_DIR / TTS_TIMEOUT / TTS_AUDIO_FORMAT. Route mounts only when
  TTS_ENDPOINT is set; otherwise unchanged behavior.
- web/audio/speech.ts: speak() hits /api/tts first, falls back to Web Speech on
  any failure; rapid-tap-safe via a request token. Call sites unchanged.
- deploy/: Piper user systemd units (EN :5005, ZH :5006), setup script, README.

English (en_US-amy-medium) and Chinese (zh_CN-huayan-medium) are both live.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 11:34:52 -07:00
parent adc0cdff1c
commit 5120b1e5a2
10 changed files with 700 additions and 10 deletions

65
deploy/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Deploying read-aloud (Piper TTS) to millenia
Petal's read-aloud generates audio with a local **Piper** neural-TTS server and
transcodes it to mp3 with **ffmpeg**. Both run on millenia (192.168.1.212);
nothing leaves the box. If Piper is down or `TTS_ENDPOINT` is unset, the frontend
falls back to the browser's Web Speech API automatically.
## 1. Piper as a systemd service (one-time)
```bash
# from this repo, on your workstation:
scp deploy/piper.service deploy/setup-piper.sh 192.168.1.212:/tmp/
ssh 192.168.1.212 'cd /tmp && sudo ./setup-piper.sh'
```
`setup-piper.sh` creates `~/piper/venv`, installs `piper-tts[http]`, downloads the
`en_US-amy-medium` voice into `~/piper/voices`, installs+enables `piper.service`
(loopback :5005), and smoke-tests it. Idempotent.
Check it any time: `systemctl status piper`, `journalctl -u piper -f`.
## 2. Point petal at Piper + redeploy the binary
Add to petal's environment (its `.env` or launch env):
```
TTS_ENDPOINT=http://127.0.0.1:5005
TTS_VOICE_EN=en_US-amy-medium
TTS_AUDIO_FORMAT=mp3
```
Then ship the rebuilt binary (`go build -o petal ./cmd/server` already done) and
restart the petal `:8088` session. Confirm the log line:
`read-aloud enabled (TTS endpoint=http://127.0.0.1:5005)`.
## 3. Verify
```bash
# on millenia — end-to-end through petal, including ffmpeg transcode:
curl -sf -X POST localhost:8088/api/tts \
-H 'Content-Type: application/json' \
-d '{"text":"hello there","lang":"en-US"}' -o /tmp/petal-tts.mp3 \
&& file /tmp/petal-tts.mp3 # expect: Audio file ... MPEG ... layer III
```
- Second identical call is served from the cache (`~/petal/.../data/tts/*.mp3`).
- A language with no configured instance returns 404 → client uses Web Speech.
- Browser check via the uitest harness (`~/petal/uitest`): tap a word in the
WordCard / select a sentence and hit speak — expect the natural Piper voice.
## Chinese voice (live)
Each Piper HTTP server loads ONE model, so Chinese runs as a **second instance**:
`piper-zh.service` on :5006 with `zh_CN-huayan-medium`. Deployed via:
```bash
scp deploy/piper-zh.service 192.168.1.212:~/.config/systemd/user/
ssh 192.168.1.212 'export XDG_RUNTIME_DIR=/run/user/$(id -u)
~/piper/venv/bin/python -m piper.download_voices zh_CN-huayan-medium --data-dir ~/piper/voices
systemctl --user daemon-reload && systemctl --user enable --now piper-zh.service'
```
Then in petal's `start.sh`: `TTS_ENDPOINT_ZH=http://127.0.0.1:5006` and
`TTS_VOICE_ZH=zh_CN-huayan-medium`. The handler maps language → instance from config,
so adding more languages is just another instance + env pair (no code change).

17
deploy/piper-zh.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Piper TTS HTTP server — Chinese voice (read-aloud backend for petal)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Bound to loopback only — petal proxies to it; never exposed off-box.
ExecStart=%h/piper/venv/bin/python -m piper.http_server \
-m zh_CN-huayan-medium \
--data-dir %h/piper/voices \
--host 127.0.0.1 --port 5006
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target

17
deploy/piper.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Piper TTS HTTP server (read-aloud backend for petal)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Bound to loopback only — petal proxies to it; never exposed off-box.
ExecStart=%h/piper/venv/bin/python -m piper.http_server \
-m en_US-amy-medium \
--data-dir %h/piper/voices \
--host 127.0.0.1 --port 5005
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target

43
deploy/setup-piper.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# One-time Piper TTS setup on millenia as a *user* systemd service (no sudo).
# Idempotent — safe to re-run. Run on the box:
# bash setup-piper.sh
# To survive logout/reboot, also (once, needs root):
# sudo loginctl enable-linger "$USER"
set -euo pipefail
PIPER_HOME="$HOME/piper"
VENV="$PIPER_HOME/venv"
VOICES="$PIPER_HOME/voices"
VOICE=en_US-amy-medium
UNIT_DIR="$HOME/.config/systemd/user"
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
echo ">> ffmpeg check (petal transcodes Piper WAV -> mp3 with it):"
command -v ffmpeg >/dev/null || { echo "ffmpeg not found on PATH"; exit 1; }
echo ">> venv + piper-tts[http]"
[ -d "$VENV" ] || python3 -m venv "$VENV"
"$VENV/bin/pip" install --upgrade pip >/dev/null
"$VENV/bin/pip" install "piper-tts[http]"
echo ">> download voice $VOICE"
mkdir -p "$VOICES"
"$VENV/bin/python" -m piper.download_voices "$VOICE" --data-dir "$VOICES"
echo ">> install user service"
mkdir -p "$UNIT_DIR"
install -m 0644 "$(dirname "$0")/piper.service" "$UNIT_DIR/piper.service"
systemctl --user daemon-reload
systemctl --user enable --now piper.service
echo ">> wait + status"
sleep 2
systemctl --user --no-pager --full status piper.service | head -n 6 || true
echo ">> smoke test"
curl -sf -X POST localhost:5005/ \
-H 'Content-Type: application/json' \
-d "{\"text\":\"hello there\",\"voice\":\"$VOICE\"}" -o /tmp/piper-test.wav \
&& echo "OK: /tmp/piper-test.wav ($(stat -c%s /tmp/piper-test.wav) bytes)" \
|| { echo "smoke test FAILED"; journalctl --user -u piper.service --no-pager | tail -n 20; exit 1; }