Files
Pete/internal/web/tts.go
prosolis dbcb459908 Add server-side Piper read-aloud with voice picker
Reader read-aloud now streams neural WAV audio from a new POST /api/tts
endpoint that shells out to Piper, instead of the browser's Web Speech
voice. Each paragraph is synthesized on demand with the next one
prefetched during playback, keeping the existing highlight/scroll sync.

Voices are configured under [web.tts] (piper binary + voices_dir + a
labelled voice list) and exposed to the client as window.PETE_TTS; the
reader gets a Voice selector in the Aa menu, persisted per-device. Still
a signed-in-only perk and gated on auth.
2026-07-07 23:00:27 -07:00

200 lines
6.1 KiB
Go

package web
import (
"bytes"
"context"
"encoding/json"
"html/template"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"pete/internal/config"
)
const (
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
ttsTimeout = 60 * time.Second
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
)
// ttsVoice is one selectable voice, as sent to the client.
type ttsVoice struct {
ID string `json:"id"`
Label string `json:"label"`
}
// ttsService runs Piper (https://github.com/rhasspy/piper) as a subprocess to
// synthesize read-aloud audio server-side. It holds the validated voice
// registry and a concurrency semaphore; there is no long-lived process, each
// request spawns a short-lived piper that loads its model, synthesizes one
// chunk of text to a WAV on stdout, and exits (~0.5s for a paragraph).
type ttsService struct {
piperBin string
voices []ttsVoice // menu order, for the client selector
models map[string]string // voice id -> absolute .onnx path
def string // default voice id
sem chan struct{} // buffered to ttsMaxConcurrent
}
// newTTS validates the Piper install and builds the voice registry. It returns
// (nil, nil) when TTS is disabled. A configured voice whose model file is
// missing is skipped with a warning rather than failing startup; if that leaves
// no usable voices, TTS is disabled.
func newTTS(cfg config.TTSConfig) (*ttsService, error) {
if !cfg.Enabled {
return nil, nil
}
bin := cfg.PiperBin
if bin == "" {
bin = "piper"
}
if p, err := exec.LookPath(bin); err != nil {
slog.Error("web: TTS enabled but piper binary not found; read-aloud disabled", "piper_bin", bin, "err", err)
return nil, nil
} else {
bin = p
}
dir := cfg.VoicesDir
svc := &ttsService{piperBin: bin, models: make(map[string]string)}
want := cfg.Voices
if len(want) == 0 {
// Auto-discover every *.onnx in voices_dir, labelled by filename stem.
entries, err := os.ReadDir(dir)
if err != nil {
slog.Error("web: TTS voices_dir unreadable; read-aloud disabled", "voices_dir", dir, "err", err)
return nil, nil
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".onnx") {
continue
}
id := strings.TrimSuffix(name, ".onnx")
want = append(want, config.VoiceConfig{ID: id, Label: id})
}
sort.Slice(want, func(i, j int) bool { return want[i].ID < want[j].ID })
}
for _, v := range want {
if v.ID == "" {
continue
}
model := filepath.Join(dir, v.ID+".onnx")
if _, err := os.Stat(model); err != nil {
slog.Warn("web: TTS voice model missing; skipping", "voice", v.ID, "path", model)
continue
}
label := v.Label
if label == "" {
label = v.ID
}
svc.models[v.ID] = model
svc.voices = append(svc.voices, ttsVoice{ID: v.ID, Label: label})
}
if len(svc.voices) == 0 {
slog.Error("web: TTS enabled but no usable voices found; read-aloud disabled", "voices_dir", dir)
return nil, nil
}
svc.def = cfg.Default
if _, ok := svc.models[svc.def]; !ok {
svc.def = svc.voices[0].ID
}
svc.sem = make(chan struct{}, ttsMaxConcurrent)
slog.Info("web: server-side TTS enabled", "piper", bin, "voices", len(svc.voices), "default", svc.def)
return svc, nil
}
// clientConfig is the JSON handed to the page as window.PETE_TTS so the reader
// can build its voice selector and know TTS is available.
func (t *ttsService) clientConfig() template.JS {
payload := struct {
Enabled bool `json:"enabled"`
Default string `json:"default"`
Voices []ttsVoice `json:"voices"`
}{Enabled: true, Default: t.def, Voices: t.voices}
b, err := json.Marshal(payload)
if err != nil {
return template.JS("null")
}
return jsForScript(b)
}
// handleTTS synthesizes one chunk of text to WAV audio with the requested
// voice. It is registered only under the authenticated route group, so callers
// are already signed in (read-aloud is a signed-in perk). The request body is
// JSON {voice, text}; the response is audio/wav.
func (s *Server) handleTTS(w http.ResponseWriter, r *http.Request) {
if s.tts == nil {
http.Error(w, "tts disabled", http.StatusNotFound)
return
}
if s.auth == nil || s.auth.userFromRequest(r) == nil {
http.Error(w, "sign-in required", http.StatusUnauthorized)
return
}
var req struct {
Voice string `json:"voice"`
Text string `json:"text"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
text := strings.TrimSpace(req.Text)
if text == "" {
http.Error(w, "empty text", http.StatusBadRequest)
return
}
if len(text) > ttsMaxTextLen {
text = text[:ttsMaxTextLen]
}
model, ok := s.tts.models[req.Voice]
if !ok {
model = s.tts.models[s.tts.def] // unknown/blank voice -> default
}
// Bound concurrent piper processes; give up if the client leaves or we wait
// too long for a slot rather than queueing unboundedly.
slotCtx, cancelSlot := context.WithTimeout(r.Context(), ttsTimeout)
defer cancelSlot()
select {
case s.tts.sem <- struct{}{}:
defer func() { <-s.tts.sem }()
case <-slotCtx.Done():
http.Error(w, "tts busy", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), ttsTimeout)
defer cancel()
// piper -m <model> -f - : write a full WAV to stdout. Text is fed on stdin,
// so there is no shell and nothing user-controlled reaches the arg list
// besides the model path, which is looked up from a fixed allowlist above.
cmd := exec.CommandContext(ctx, s.tts.piperBin, "-m", model, "-f", "-")
cmd.Stdin = strings.NewReader(text)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
slog.Error("web: piper synthesis failed", "err", err, "stderr", strings.TrimSpace(errb.String()))
http.Error(w, "synthesis failed", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "audio/wav")
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Header().Set("Content-Length", strconv.Itoa(out.Len()))
_, _ = w.Write(out.Bytes())
}