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
265 lines
9.2 KiB
Go
265 lines
9.2 KiB
Go
// Package tts implements read-aloud: it proxies short passages to a local Piper
|
|
// HTTP server (a fast, offline neural TTS), optionally transcodes Piper's WAV to
|
|
// mp3/opus with ffmpeg, and serves the audio back to the editor. Synthesized
|
|
// clips are content-addressed on disk so tapping the same word twice is instant
|
|
// and never re-synthesizes. The feature is entirely optional: when TTS_ENDPOINT
|
|
// is unset the route isn't mounted and the frontend falls back to the browser's
|
|
// Web Speech API.
|
|
package tts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
|
)
|
|
|
|
// maxTextBytes bounds a single synthesis request. Read-aloud is for a word or a
|
|
// sentence or two, not whole documents; this keeps one tap from pinning Piper.
|
|
const maxTextBytes = 4000
|
|
|
|
// lengthScale slows Piper slightly below natural pace — a learner following along
|
|
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
|
|
const lengthScale = 1.1
|
|
|
|
// audioFormat describes one output encoding: the cache-file extension, the
|
|
// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin)
|
|
// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is".
|
|
type audioFormat struct {
|
|
ext string
|
|
contentType string
|
|
ffmpegArgs []string
|
|
}
|
|
|
|
// formats maps the configured TTS_AUDIO_FORMAT to its encoding. mp3 is the safe
|
|
// default (every browser plays it); opus is ~half the size for speech but has
|
|
// patchier Safari support; wav skips ffmpeg entirely.
|
|
var formats = map[string]audioFormat{
|
|
"wav": {ext: ".wav", contentType: "audio/wav"},
|
|
"mp3": {ext: ".mp3", contentType: "audio/mpeg",
|
|
ffmpegArgs: []string{"-f", "mp3", "-c:a", "libmp3lame", "-b:a", "64k", "-ac", "1"}},
|
|
"opus": {ext: ".opus", contentType: "audio/ogg",
|
|
ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}},
|
|
}
|
|
|
|
// route is the Piper instance and voice id serving one language. Each Piper
|
|
// HTTP server loads exactly one model, so distinct languages mean distinct
|
|
// endpoints (e.g. English on :5005, Chinese on :5006).
|
|
type route struct {
|
|
endpoint string // Piper HTTP base URL (no trailing slash)
|
|
voice string // Piper voice id (sent in the request; also part of the cache key)
|
|
}
|
|
|
|
// Handler proxies synthesis to Piper and caches the result on disk.
|
|
type Handler struct {
|
|
routes map[string]route // base language (e.g. "en", "zh") -> Piper instance
|
|
cacheDir string
|
|
format audioFormat
|
|
client *http.Client
|
|
}
|
|
|
|
// New builds a Handler from config. It returns (nil, false) when TTS_ENDPOINT is
|
|
// unset, signalling main to skip mounting the route. An unknown TTS_AUDIO_FORMAT
|
|
// falls back to mp3 rather than failing the whole server.
|
|
func New(cfg *config.Config) (*Handler, bool) {
|
|
if strings.TrimSpace(cfg.TTSEndpoint) == "" {
|
|
return nil, false
|
|
}
|
|
|
|
format, ok := formats[strings.ToLower(strings.TrimSpace(cfg.TTSFormat))]
|
|
if !ok {
|
|
format = formats["mp3"]
|
|
}
|
|
|
|
// Map by base language so en-US, en-GB, etc. all resolve to the English
|
|
// instance (the client sends BCP-47 tags like the old Web Speech path did).
|
|
// A language is only routable when both its endpoint and voice are set;
|
|
// otherwise the client falls back to Web Speech for that language.
|
|
routes := map[string]route{}
|
|
if cfg.TTSVoiceEN != "" {
|
|
routes["en"] = route{strings.TrimRight(cfg.TTSEndpoint, "/"), cfg.TTSVoiceEN}
|
|
}
|
|
if cfg.TTSEndpointZH != "" && cfg.TTSVoiceZH != "" {
|
|
routes["zh"] = route{strings.TrimRight(cfg.TTSEndpointZH, "/"), cfg.TTSVoiceZH}
|
|
}
|
|
|
|
if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
|
|
// A missing cache dir isn't fatal — synthesis still works, it just won't
|
|
// cache. Disable the feature only on endpoint absence, not this.
|
|
fmt.Fprintf(os.Stderr, "tts: cache dir %q: %v\n", cfg.TTSCacheDir, err)
|
|
}
|
|
|
|
return &Handler{
|
|
routes: routes,
|
|
cacheDir: cfg.TTSCacheDir,
|
|
format: format,
|
|
client: &http.Client{Timeout: cfg.TTSTimeout},
|
|
}, true
|
|
}
|
|
|
|
// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
|
|
// POST /api/tts.
|
|
func (h *Handler) Routes() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Post("/", h.synth)
|
|
return r
|
|
}
|
|
|
|
// synthRequest is the body the editor posts: a passage and the BCP-47 language
|
|
// tag it's written in (e.g. "en-US", "zh-CN").
|
|
type synthRequest struct {
|
|
Text string `json:"text"`
|
|
Lang string `json:"lang"`
|
|
}
|
|
|
|
// synth resolves a voice for the requested language, returns cached audio when
|
|
// present, and otherwise asks Piper to synthesize, transcodes if configured,
|
|
// caches, and serves. An unconfigured language yields 404 so the client can fall
|
|
// back to Web Speech without treating it as an error.
|
|
func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
|
|
var req synthRequest
|
|
// Cap the raw body generously above maxTextBytes to leave room for JSON
|
|
// framing and the lang field; the text itself is truncated after decoding.
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, maxTextBytes+1024)).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
text := strings.TrimSpace(req.Text)
|
|
if text == "" {
|
|
http.Error(w, "empty text", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(text) > maxTextBytes {
|
|
// Trim back to the last valid rune boundary so we never hand Piper a
|
|
// half-encoded multibyte character (common with Chinese, 3 bytes/char).
|
|
text = text[:maxTextBytes]
|
|
for len(text) > 0 && !utf8.ValidString(text) {
|
|
text = text[:len(text)-1]
|
|
}
|
|
}
|
|
|
|
rt, ok := h.routes[baseLang(req.Lang)]
|
|
if !ok {
|
|
// No voice for this language — let the client fall back to Web Speech.
|
|
http.Error(w, "no voice for language", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Content-addressed: identical (voice, text) → identical clip. The format
|
|
// extension keeps encodings from colliding in the same dir.
|
|
sum := sha256.Sum256([]byte(rt.voice + "\n" + text))
|
|
name := hex.EncodeToString(sum[:])[:32] + h.format.ext
|
|
path := filepath.Join(h.cacheDir, name)
|
|
|
|
if _, err := os.Stat(path); err == nil {
|
|
h.serve(w, r, path)
|
|
return
|
|
}
|
|
|
|
audio, err := h.synthesize(r.Context(), rt, text)
|
|
if err != nil {
|
|
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
|
fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
|
|
return
|
|
}
|
|
|
|
// Best-effort cache write via a temp file + rename so a concurrent reader
|
|
// never sees a half-written clip. A failed write just means no caching.
|
|
if h.cacheDir != "" {
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, audio, 0o644); err == nil {
|
|
_ = os.Rename(tmp, path)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", h.format.contentType)
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
_, _ = w.Write(audio)
|
|
}
|
|
|
|
// serve streams a cached clip with a long-lived immutable cache header (the URL
|
|
// is content-addressed, so the bytes never change for a given request).
|
|
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {
|
|
w.Header().Set("Content-Type", h.format.contentType)
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
http.ServeFile(w, r, path)
|
|
}
|
|
|
|
// synthesize POSTs to the route's Piper instance, then transcodes the returned
|
|
// WAV when the configured format calls for it.
|
|
func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte, error) {
|
|
body, _ := json.Marshal(map[string]any{
|
|
"text": text,
|
|
"voice": rt.voice,
|
|
"length_scale": lengthScale,
|
|
})
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+"/", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := h.client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
|
return nil, fmt.Errorf("piper %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
|
}
|
|
wav, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if h.format.ffmpegArgs == nil {
|
|
return wav, nil
|
|
}
|
|
return transcode(ctx, wav, h.format.ffmpegArgs)
|
|
}
|
|
|
|
// transcode pipes WAV bytes through ffmpeg (stdin → stdout) into the target
|
|
// encoding. ffmpeg is assumed on PATH; an error here surfaces as a 502.
|
|
func transcode(ctx context.Context, wav []byte, args []string) ([]byte, error) {
|
|
// Guard ffmpeg against a hang independent of the HTTP client timeout.
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
full := append([]string{"-hide_banner", "-loglevel", "error", "-i", "pipe:0"}, args...)
|
|
full = append(full, "pipe:1")
|
|
cmd := exec.CommandContext(ctx, "ffmpeg", full...)
|
|
cmd.Stdin = bytes.NewReader(wav)
|
|
var out, errBuf bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &errBuf
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("ffmpeg: %v: %s", err, strings.TrimSpace(errBuf.String()))
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// baseLang reduces a BCP-47 tag to its primary subtag, lowercased: "en-US" → "en",
|
|
// "zh-CN" → "zh", "" → "". Mirrors the client's pickVoice base-language matching.
|
|
func baseLang(tag string) string {
|
|
tag = strings.ToLower(strings.TrimSpace(tag))
|
|
if i := strings.IndexAny(tag, "-_"); i >= 0 {
|
|
return tag[:i]
|
|
}
|
|
return tag
|
|
}
|