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.
200 lines
6.1 KiB
Go
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())
|
|
}
|