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
171 lines
5.1 KiB
Go
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)))
|
|
}
|
|
}
|