Files
petal/internal/tts/handler_test.go
T
prosolis 24c3533e18 Give read-aloud a Portuguese voice, and a slower one
Phase 21's infra half. Two things the pt-PT pair needs from TTS, and one
thing every learner has wanted since Phase 11.

**A language is no longer a code change.** The handler knew exactly two
languages, named in the Config struct: English on TTS_ENDPOINT and Chinese
on TTS_ENDPOINT_ZH. Petal now discovers its Piper instances from the
environment — English keeps the unsuffixed pair it has always had, and
every other language is a TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair — so
fr and es cost a compose service and two lines of .env. <LANG> is the base
tag, because an environment variable name cannot hold pt-PT's hyphen and
only one Portuguese model is loaded either way. A language configured by
halves is dropped rather than routed: half a configuration should reach
the client as "no voice here, use Web Speech", not as an instance that
errors on every tap. The startup line now names the voices it actually
resolved rather than the English endpoint it was handed — the same lesson
the dictionary line learned last week.

**pt_PT-tugão-medium is the only European voice Piper ships.** The other
five pt models in the catalogue are Brazilian, so the default anyone
reaches for is the wrong country — the same trap as `dictionary-pt`
packaging VERO, arriving through the catalogue rather than through the
model. Named explicitly in compose, with the query that checks it in the
deploy README.

**The slow replay** (SUGGESTIONS §5e) is `slow: true` on /api/tts, raising
Piper's length_scale to ~4/3. Piper stretches durations rather than
resampling, so it stays a voice instead of a groan. The pace is part of
the cache key — without it the slow replay of a word already heard at
normal speed would be served back at normal speed, which is the one
request where the difference is the whole point. 🐢 sits beside 🔊 on the
word card, the selection bubble and the garden flashcard; the Web Speech
fallback slows too, so the button means the same thing when Piper is down.

**And the other reading gets her own voice.** The `alsoIn` block — the
Portuguese sense of a word that is also English — now speaks in the pair's
locale, which the pack names (`locale`) rather than anything inferring it
from the letters. "comum" is spelled identically in both halves; a
detector would have to guess, and this is the same reason the gloss shows
both directions instead of picking one.

Tests: config discovery (both existing deployment shapes, half-configured
languages dropped, the pre-map voice defaults preserved), the slow scale
and its separate cache entry, pt routing on the base tag with pt-BR
landing on the European instance, and speech.ts's request body. The i18n
shape suite now asserts every pack names a speakable locale in its own
language — and that pt-PT's is not pt-BR.

Verified: go build/vet/test, tsc, vitest 125/125, vite build. Live smoke
against two fake Piper servers: en/pt × normal/slow all reached the right
instance at the right length_scale with four distinct cache entries, and
an unconfigured language still 404s.
2026-07-27 13:21:45 -07:00

303 lines
9.8 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)
last.scale, _ = req["length_scale"].(float64)
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv, &calls, last
}
type synthEcho struct {
voice, text string
scale float64
}
// 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"}},
synthURI: synthPath("/"),
cacheDir: t.TempDir(),
format: formats["wav"],
client: http.DefaultClient,
}
}
// Piper 1.6.0 serves synthesis on /synthesize and 405s on /. The path is
// configuration (TTS_PATH) so one Petal build talks to either server version;
// this asserts the configured path is the one actually requested.
func TestSynthUsesConfiguredPath(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.URL.Path != "/synthesize" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write([]byte("RIFF....fake-wav"))
}))
t.Cleanup(srv.Close)
h := newHandler(t, srv.URL)
h.synthURI = synthPath("/synthesize")
if rr := post(t, h, "hello there", "en-US"); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (piper saw path %q)", rr.Code, gotPath)
}
if gotPath != "/synthesize" {
t.Errorf("piper path = %q, want /synthesize", gotPath)
}
}
func TestSynthPathNormalisation(t *testing.T) {
cases := map[string]string{
"": "/",
"/": "/",
"synthesize": "/synthesize",
"/synthesize": "/synthesize",
"/synthesize/": "/synthesize",
" /v1/tts ": "/v1/tts",
}
for in, want := range cases {
if got := synthPath(in); got != want {
t.Errorf("synthPath(%q) = %q, want %q", in, got, want)
}
}
}
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
t.Helper()
return postReq(t, h, synthRequest{Text: text, Lang: lang})
}
func postReq(t *testing.T, h *Handler, body synthRequest) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
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)
}
}
// The slow replay is the whole of SUGGESTIONS §5e: same text, same voice, more
// time per phoneme.
func TestSlowRequestStretchesTheVoice(t *testing.T) {
srv, _, last := newStubPiper(t, []byte("RIFF....fake-wav"))
h := newHandler(t, srv.URL)
if rr := postReq(t, h, synthRequest{Text: "reception", Lang: "en-US"}); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if last.scale != lengthScale {
t.Fatalf("normal length_scale = %v, want %v", last.scale, lengthScale)
}
if rr := postReq(t, h, synthRequest{Text: "reception", Lang: "en-US", Slow: true}); rr.Code != http.StatusOK {
t.Fatalf("slow status = %d, want 200", rr.Code)
}
if last.scale != slowLengthScale {
t.Fatalf("slow length_scale = %v, want %v", last.scale, slowLengthScale)
}
if slowLengthScale <= lengthScale {
t.Fatalf("slowLengthScale %v is not slower than %v", slowLengthScale, lengthScale)
}
}
// The pace has to be part of the cache key. Without it, asking for the slow
// replay of a word already heard at normal speed serves the normal clip — the
// one request where hearing the difference is the entire point.
func TestSlowClipIsNotServedFromTheNormalCache(t *testing.T) {
srv, calls, last := newStubPiper(t, []byte("RIFF....fake-wav"))
h := newHandler(t, srv.URL)
postReq(t, h, synthRequest{Text: "reception", Lang: "en-US"})
postReq(t, h, synthRequest{Text: "reception", Lang: "en-US", Slow: true})
if *calls != 2 {
t.Fatalf("piper calls = %d, want 2 (the slow clip is a different clip)", *calls)
}
if last.scale != slowLengthScale {
t.Fatalf("second call length_scale = %v, want the slow one", last.scale)
}
// …and each pace still caches on its own.
postReq(t, h, synthRequest{Text: "reception", Lang: "en-US", Slow: true})
postReq(t, h, synthRequest{Text: "reception", Lang: "en-US"})
if *calls != 2 {
t.Fatalf("piper calls = %d, want 2 (both paces now cached)", *calls)
}
}
// A Portuguese request must reach the Portuguese instance on the base tag alone:
// env var names cannot hold the hyphen in pt-PT, so config keys the map on "pt"
// and the handler has to meet it there. pt-BR resolves to the same instance
// because there is only one Portuguese voice loaded — and it is the European one.
func TestPortugueseRoutesOnTheBaseTag(t *testing.T) {
enSrv, enCalls, _ := newStubPiper(t, []byte("EN-wav"))
ptSrv, ptCalls, ptLast := newStubPiper(t, []byte("PT-wav"))
h := &Handler{
routes: map[string]route{
"en": {strings.TrimRight(enSrv.URL, "/"), "en_US-amy-medium"},
"pt": {strings.TrimRight(ptSrv.URL, "/"), "pt_PT-tugão-medium"},
},
cacheDir: t.TempDir(),
format: formats["wav"],
client: http.DefaultClient,
}
// Distinct text per tag, so a cache hit can't stand in for a route.
for i, tag := range []string{"pt-PT", "pt", "pt-BR"} {
if rr := post(t, h, strings.Repeat("receção ", i+1), tag); rr.Code != http.StatusOK {
t.Fatalf("%s status = %d, want 200", tag, rr.Code)
}
}
if *ptCalls != 3 || *enCalls != 0 {
t.Fatalf("calls en=%d pt=%d, want en=0 pt=3", *enCalls, *ptCalls)
}
if ptLast.voice != "pt_PT-tugão-medium" {
t.Fatalf("pt voice = %q, want the European Portuguese voice", ptLast.voice)
}
}
func TestBaseLang(t *testing.T) {
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "pt-PT": "pt", "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)))
}
}