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 -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()) }