Phase 21's infra half. Two things the pt-PT pair needs from TTS, and one thing every learner has wanted since Phase 11. **A language is no longer a code change.** The handler knew exactly two languages, named in the Config struct: English on TTS_ENDPOINT and Chinese on TTS_ENDPOINT_ZH. Petal now discovers its Piper instances from the environment — English keeps the unsuffixed pair it has always had, and every other language is a TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair — so fr and es cost a compose service and two lines of .env. <LANG> is the base tag, because an environment variable name cannot hold pt-PT's hyphen and only one Portuguese model is loaded either way. A language configured by halves is dropped rather than routed: half a configuration should reach the client as "no voice here, use Web Speech", not as an instance that errors on every tap. The startup line now names the voices it actually resolved rather than the English endpoint it was handed — the same lesson the dictionary line learned last week. **pt_PT-tugão-medium is the only European voice Piper ships.** The other five pt models in the catalogue are Brazilian, so the default anyone reaches for is the wrong country — the same trap as `dictionary-pt` packaging VERO, arriving through the catalogue rather than through the model. Named explicitly in compose, with the query that checks it in the deploy README. **The slow replay** (SUGGESTIONS §5e) is `slow: true` on /api/tts, raising Piper's length_scale to ~4/3. Piper stretches durations rather than resampling, so it stays a voice instead of a groan. The pace is part of the cache key — without it the slow replay of a word already heard at normal speed would be served back at normal speed, which is the one request where the difference is the whole point. 🐢 sits beside 🔊 on the word card, the selection bubble and the garden flashcard; the Web Speech fallback slows too, so the button means the same thing when Piper is down. **And the other reading gets her own voice.** The `alsoIn` block — the Portuguese sense of a word that is also English — now speaks in the pair's locale, which the pack names (`locale`) rather than anything inferring it from the letters. "comum" is spelled identically in both halves; a detector would have to guess, and this is the same reason the gloss shows both directions instead of picking one. Tests: config discovery (both existing deployment shapes, half-configured languages dropped, the pre-map voice defaults preserved), the slow scale and its separate cache entry, pt routing on the base tag with pt-BR landing on the European instance, and speech.ts's request body. The i18n shape suite now asserts every pack names a speakable locale in its own language — and that pt-PT's is not pt-BR. Verified: go build/vet/test, tsc, vitest 125/125, vite build. Live smoke against two fake Piper servers: en/pt × normal/slow all reached the right instance at the right length_scale with four distinct cache entries, and an unconfigured language still 404s.
314 lines
11 KiB
Go
314 lines
11 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"
|
||
"sort"
|
||
"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
|
||
|
||
// slowLengthScale is the "say it slower" replay (SUGGESTIONS §5e): roughly 0.75×
|
||
// the normal pace, which is the speed listening drills have used for decades.
|
||
// Piper stretches durations rather than resampling, so the voice keeps its pitch
|
||
// instead of turning into a slowed tape.
|
||
const slowLengthScale = lengthScale / 0.75
|
||
|
||
// 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"}},
|
||
}
|
||
|
||
// synthPath normalises TTS_PATH into a leading-slash path with no trailing
|
||
// slash, so it concatenates cleanly onto a route's endpoint.
|
||
//
|
||
// Piper moved synthesis from `POST /` to `POST /synthesize` in 1.6.0, and the
|
||
// request body is identical either side of that change. Rather than pinning
|
||
// every deployment to one Piper release, the path is configuration: millenia
|
||
// keeps the default `/` its installed server expects, and the containerised
|
||
// 1.6.0 sidecars on the VPS set `/synthesize`.
|
||
func synthPath(p string) string {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" || p == "/" {
|
||
return "/"
|
||
}
|
||
if !strings.HasPrefix(p, "/") {
|
||
p = "/" + p
|
||
}
|
||
return strings.TrimRight(p, "/")
|
||
}
|
||
|
||
// 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
|
||
synthURI string // path Piper serves synthesis on (see TTSPath)
|
||
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"]
|
||
}
|
||
|
||
// Keyed by base language so en-US, en-GB — and pt-PT, pt-BR, bare pt —
|
||
// resolve to the one instance that has that language's model loaded (the
|
||
// client sends BCP-47 tags, as the old Web Speech path did). Config has
|
||
// already dropped any language configured by halves, so an unroutable
|
||
// language reaches the client as a 404 and falls back to Web Speech.
|
||
routes := map[string]route{}
|
||
for lang, v := range cfg.TTSVoices {
|
||
routes[lang] = route{endpoint: v.Endpoint, voice: v.Voice}
|
||
}
|
||
|
||
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,
|
||
synthURI: synthPath(cfg.TTSPath),
|
||
cacheDir: cfg.TTSCacheDir,
|
||
format: format,
|
||
client: &http.Client{Timeout: cfg.TTSTimeout},
|
||
}, true
|
||
}
|
||
|
||
// Languages lists the base language tags this handler can synthesize, sorted,
|
||
// each with the voice serving it — for the startup line, so a deployment says
|
||
// which sidecars it actually reached rather than which ones it was configured
|
||
// to want.
|
||
func (h *Handler) Languages() []string {
|
||
out := make([]string, 0, len(h.routes))
|
||
for lang, rt := range h.routes {
|
||
out = append(out, lang+"="+rt.voice)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// 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, the BCP-47 language tag
|
||
// it's written in (e.g. "en-US", "zh-CN", "pt-PT"), and whether to say it slowly
|
||
// — the replay a learner reaches for when the sentence went past too fast.
|
||
type synthRequest struct {
|
||
Text string `json:"text"`
|
||
Lang string `json:"lang"`
|
||
Slow bool `json:"slow"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
scale := lengthScale
|
||
if req.Slow {
|
||
scale = slowLengthScale
|
||
}
|
||
|
||
// Content-addressed: identical (voice, pace, text) → identical clip. The pace
|
||
// belongs in the key — without it the slow replay of a word already heard at
|
||
// normal speed would be served from cache at normal speed, which is the one
|
||
// request where the difference is the whole point. The format extension keeps
|
||
// encodings from colliding in the same dir.
|
||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s\n%.3f\n%s", rt.voice, scale, 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, scale)
|
||
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, scale float64) ([]byte, error) {
|
||
body, _ := json.Marshal(map[string]any{
|
||
"text": text,
|
||
"voice": rt.voice,
|
||
"length_scale": scale,
|
||
})
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+h.synthURI, 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
|
||
}
|