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