// 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 }