Files
petal/internal/tts/handler_test.go
prosolis 5120b1e5a2 Read-aloud: natural neural voice via local Piper TTS
Replace the browser's robotic Web Speech API (espeak on Chromium) as the
primary read-aloud path with server-side Piper neural TTS, served by petal
and kept fully offline on millenia next to Ollama.

- internal/tts: proxy short passages to Piper, transcode WAV -> mp3/opus via
  ffmpeg, content-addressed disk cache (instant re-taps). Each Piper instance
  loads one voice, so language routes to its own endpoint:{voice}. Unknown
  language -> 404 so the client falls back to Web Speech. UTF-8-safe truncation
  for multibyte (Chinese) text.
- config: TTS_ENDPOINT / TTS_ENDPOINT_ZH / TTS_VOICE_EN / TTS_VOICE_ZH /
  TTS_CACHE_DIR / TTS_TIMEOUT / TTS_AUDIO_FORMAT. Route mounts only when
  TTS_ENDPOINT is set; otherwise unchanged behavior.
- web/audio/speech.ts: speak() hits /api/tts first, falls back to Web Speech on
  any failure; rapid-tap-safe via a request token. Call sites unchanged.
- deploy/: Piper user systemd units (EN :5005, ZH :5006), setup script, README.

English (en_US-amy-medium) and Chinese (zh_CN-huayan-medium) are both live.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 11:34:52 -07:00

171 lines
5.1 KiB
Go

package tts
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// newStubPiper returns a fake Piper server that echoes a fixed WAV body and
// records how many times it was called and the last request payload.
func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEcho) {
t.Helper()
var calls int32
last := &synthEcho{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
last.voice, _ = req["voice"].(string)
last.text, _ = req["text"].(string)
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv, &calls, last
}
type synthEcho struct{ voice, text string }
// newHandler builds a wav-format handler (no ffmpeg) pointed at a stub server.
func newHandler(t *testing.T, endpoint string) *Handler {
t.Helper()
return &Handler{
routes: map[string]route{"en": {strings.TrimRight(endpoint, "/"), "en_US-amy-medium"}},
cacheDir: t.TempDir(),
format: formats["wav"],
client: http.DefaultClient,
}
}
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b))
rr := httptest.NewRecorder()
h.synth(rr, req)
return rr
}
func TestSynthSuccessAndVoiceSelection(t *testing.T) {
wav := []byte("RIFF....fake-wav")
srv, calls, last := newStubPiper(t, wav)
h := newHandler(t, srv.URL)
rr := post(t, h, "hello there", "en-US")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.Bytes(); !bytes.Equal(got, wav) {
t.Fatalf("body = %q, want the piper wav", got)
}
if ct := rr.Header().Get("Content-Type"); ct != "audio/wav" {
t.Fatalf("content-type = %q, want audio/wav", ct)
}
if last.voice != "en_US-amy-medium" {
t.Fatalf("piper voice = %q, want en_US-amy-medium", last.voice)
}
if *calls != 1 {
t.Fatalf("piper calls = %d, want 1", *calls)
}
}
func TestRoutesByLanguageToSeparateInstances(t *testing.T) {
enSrv, enCalls, _ := newStubPiper(t, []byte("EN-wav"))
zhSrv, zhCalls, zhLast := newStubPiper(t, []byte("ZH-wav"))
h := &Handler{
routes: map[string]route{
"en": {strings.TrimRight(enSrv.URL, "/"), "en_US-amy-medium"},
"zh": {strings.TrimRight(zhSrv.URL, "/"), "zh_CN-huayan-medium"},
},
cacheDir: t.TempDir(),
format: formats["wav"],
client: http.DefaultClient,
}
if rr := post(t, h, "你好世界", "zh-CN"); rr.Code != http.StatusOK {
t.Fatalf("zh status = %d, want 200", rr.Code)
}
if *zhCalls != 1 || *enCalls != 0 {
t.Fatalf("calls en=%d zh=%d, want en=0 zh=1 (zh routed to zh instance)", *enCalls, *zhCalls)
}
if zhLast.voice != "zh_CN-huayan-medium" {
t.Fatalf("zh voice = %q, want zh_CN-huayan-medium", zhLast.voice)
}
}
func TestUnknownLanguageReturns404(t *testing.T) {
srv, calls, _ := newStubPiper(t, []byte("x"))
h := newHandler(t, srv.URL)
rr := post(t, h, "你好", "zh-CN") // only "en" is configured
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
if *calls != 0 {
t.Fatalf("piper calls = %d, want 0 (should not synthesize)", *calls)
}
}
func TestCacheHitSkipsPiper(t *testing.T) {
srv, calls, _ := newStubPiper(t, []byte("RIFF....fake-wav"))
h := newHandler(t, srv.URL)
if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK {
t.Fatalf("first status = %d, want 200", rr.Code)
}
if rr := post(t, h, "same words", "en-US"); rr.Code != http.StatusOK {
t.Fatalf("second status = %d, want 200", rr.Code)
}
if *calls != 1 {
t.Fatalf("piper calls = %d, want 1 (second served from cache)", *calls)
}
}
func TestEmptyTextReturns400(t *testing.T) {
srv, _, _ := newStubPiper(t, []byte("x"))
h := newHandler(t, srv.URL)
if rr := post(t, h, " ", "en-US"); rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}
func TestTextIsCapped(t *testing.T) {
srv, _, last := newStubPiper(t, []byte("x"))
h := newHandler(t, srv.URL)
long := strings.Repeat("a", maxTextBytes+500)
if rr := post(t, h, long, "en-US"); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if len(last.text) != maxTextBytes {
t.Fatalf("piper received %d chars, want capped to %d", len(last.text), maxTextBytes)
}
}
func TestBaseLang(t *testing.T) {
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "en": "en", "": ""}
for in, want := range cases {
if got := baseLang(in); got != want {
t.Errorf("baseLang(%q) = %q, want %q", in, got, want)
}
}
}
// ensure the stub's body is fully consumable (guards against the LimitReader cap
// accidentally truncating a normal request body in synth()).
func TestRequestBodyNotTruncated(t *testing.T) {
srv, _, last := newStubPiper(t, []byte("x"))
h := newHandler(t, srv.URL)
text := strings.Repeat("word ", 200) // ~1000 bytes, under the cap
post(t, h, text, "en-US")
if last.text != strings.TrimSpace(text) {
t.Fatalf("piper text length = %d, want %d", len(last.text), len(strings.TrimSpace(text)))
}
}