diff --git a/.env.example b/.env.example index 2024024..75a22d5 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,20 @@ LLM_MODEL= # checkpoint model (small/fast). Never hardcod LLM_CHAT_MODEL= # Ask Petal model (Mandarin-native). Falls back to LLM_MODEL if empty. LLM_TIMEOUT=30s +# Read-aloud (TTS). Off unless TTS_ENDPOINT is set — when empty, the /api/tts route +# is not mounted and the frontend falls back to the browser's Web Speech API. +# Backed by a local Piper HTTP server (python3 -m piper.http_server). +# Each Piper HTTP server loads ONE voice, so English and Chinese need separate +# instances (different ports). zh is only routed when both TTS_ENDPOINT_ZH and +# TTS_VOICE_ZH are set; otherwise Chinese falls back to Web Speech. +TTS_ENDPOINT= # e.g. http://127.0.0.1:5005 — empty disables server TTS +TTS_ENDPOINT_ZH= # e.g. http://127.0.0.1:5006 — Chinese Piper instance +TTS_VOICE_EN=en_US-amy-medium # Piper voice id for English +TTS_VOICE_ZH=zh_CN-huayan-medium # Piper voice id for Chinese +TTS_CACHE_DIR=./data/tts # on-disk store for synthesized clips (content-addressed) +TTS_TIMEOUT=15s +TTS_AUDIO_FORMAT=mp3 # mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg + # --- Deferred (not wired in the local-dev build) --- # Auth (Authentik OIDC) — deferred; single hardcoded local user for now diff --git a/cmd/server/main.go b/cmd/server/main.go index ff90ceb..206bb6c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -19,6 +19,7 @@ import ( "gitea.parodia.dev/drwily/petal/internal/lexicon" "gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/suggestions" + "gitea.parodia.dev/drwily/petal/internal/tts" "gitea.parodia.dev/drwily/petal/web" ) @@ -88,6 +89,14 @@ func main() { log.Fatalf("image store: %v", err) } api.Mount("/images", imgHandler.Routes()) + + // Read-aloud: proxy short passages to a local Piper TTS server. Only + // mounted when TTS_ENDPOINT is configured; otherwise the frontend falls + // back to the browser's Web Speech API on its own. + if ttsHandler, ok := tts.New(cfg); ok { + api.Mount("/tts", ttsHandler.Routes()) + log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint) + } }) // Everything else: serve the embedded SPA (with index.html fallback for client routing). diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..6381d8e --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,65 @@ +# Deploying read-aloud (Piper TTS) to millenia + +Petal's read-aloud generates audio with a local **Piper** neural-TTS server and +transcodes it to mp3 with **ffmpeg**. Both run on millenia (192.168.1.212); +nothing leaves the box. If Piper is down or `TTS_ENDPOINT` is unset, the frontend +falls back to the browser's Web Speech API automatically. + +## 1. Piper as a systemd service (one-time) + +```bash +# from this repo, on your workstation: +scp deploy/piper.service deploy/setup-piper.sh 192.168.1.212:/tmp/ +ssh 192.168.1.212 'cd /tmp && sudo ./setup-piper.sh' +``` + +`setup-piper.sh` creates `~/piper/venv`, installs `piper-tts[http]`, downloads the +`en_US-amy-medium` voice into `~/piper/voices`, installs+enables `piper.service` +(loopback :5005), and smoke-tests it. Idempotent. + +Check it any time: `systemctl status piper`, `journalctl -u piper -f`. + +## 2. Point petal at Piper + redeploy the binary + +Add to petal's environment (its `.env` or launch env): + +``` +TTS_ENDPOINT=http://127.0.0.1:5005 +TTS_VOICE_EN=en_US-amy-medium +TTS_AUDIO_FORMAT=mp3 +``` + +Then ship the rebuilt binary (`go build -o petal ./cmd/server` already done) and +restart the petal `:8088` session. Confirm the log line: +`read-aloud enabled (TTS endpoint=http://127.0.0.1:5005)`. + +## 3. Verify + +```bash +# on millenia — end-to-end through petal, including ffmpeg transcode: +curl -sf -X POST localhost:8088/api/tts \ + -H 'Content-Type: application/json' \ + -d '{"text":"hello there","lang":"en-US"}' -o /tmp/petal-tts.mp3 \ + && file /tmp/petal-tts.mp3 # expect: Audio file ... MPEG ... layer III +``` + +- Second identical call is served from the cache (`~/petal/.../data/tts/*.mp3`). +- A language with no configured instance returns 404 → client uses Web Speech. +- Browser check via the uitest harness (`~/petal/uitest`): tap a word in the + WordCard / select a sentence and hit speak — expect the natural Piper voice. + +## Chinese voice (live) + +Each Piper HTTP server loads ONE model, so Chinese runs as a **second instance**: +`piper-zh.service` on :5006 with `zh_CN-huayan-medium`. Deployed via: + +```bash +scp deploy/piper-zh.service 192.168.1.212:~/.config/systemd/user/ +ssh 192.168.1.212 'export XDG_RUNTIME_DIR=/run/user/$(id -u) + ~/piper/venv/bin/python -m piper.download_voices zh_CN-huayan-medium --data-dir ~/piper/voices + systemctl --user daemon-reload && systemctl --user enable --now piper-zh.service' +``` + +Then in petal's `start.sh`: `TTS_ENDPOINT_ZH=http://127.0.0.1:5006` and +`TTS_VOICE_ZH=zh_CN-huayan-medium`. The handler maps language → instance from config, +so adding more languages is just another instance + env pair (no code change). diff --git a/deploy/piper-zh.service b/deploy/piper-zh.service new file mode 100644 index 0000000..0322457 --- /dev/null +++ b/deploy/piper-zh.service @@ -0,0 +1,17 @@ +[Unit] +Description=Piper TTS HTTP server — Chinese voice (read-aloud backend for petal) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Bound to loopback only — petal proxies to it; never exposed off-box. +ExecStart=%h/piper/venv/bin/python -m piper.http_server \ + -m zh_CN-huayan-medium \ + --data-dir %h/piper/voices \ + --host 127.0.0.1 --port 5006 +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/deploy/piper.service b/deploy/piper.service new file mode 100644 index 0000000..1a453da --- /dev/null +++ b/deploy/piper.service @@ -0,0 +1,17 @@ +[Unit] +Description=Piper TTS HTTP server (read-aloud backend for petal) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Bound to loopback only — petal proxies to it; never exposed off-box. +ExecStart=%h/piper/venv/bin/python -m piper.http_server \ + -m en_US-amy-medium \ + --data-dir %h/piper/voices \ + --host 127.0.0.1 --port 5005 +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/deploy/setup-piper.sh b/deploy/setup-piper.sh new file mode 100755 index 0000000..57c827a --- /dev/null +++ b/deploy/setup-piper.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# One-time Piper TTS setup on millenia as a *user* systemd service (no sudo). +# Idempotent — safe to re-run. Run on the box: +# bash setup-piper.sh +# To survive logout/reboot, also (once, needs root): +# sudo loginctl enable-linger "$USER" +set -euo pipefail + +PIPER_HOME="$HOME/piper" +VENV="$PIPER_HOME/venv" +VOICES="$PIPER_HOME/voices" +VOICE=en_US-amy-medium +UNIT_DIR="$HOME/.config/systemd/user" +export XDG_RUNTIME_DIR="/run/user/$(id -u)" + +echo ">> ffmpeg check (petal transcodes Piper WAV -> mp3 with it):" +command -v ffmpeg >/dev/null || { echo "ffmpeg not found on PATH"; exit 1; } + +echo ">> venv + piper-tts[http]" +[ -d "$VENV" ] || python3 -m venv "$VENV" +"$VENV/bin/pip" install --upgrade pip >/dev/null +"$VENV/bin/pip" install "piper-tts[http]" + +echo ">> download voice $VOICE" +mkdir -p "$VOICES" +"$VENV/bin/python" -m piper.download_voices "$VOICE" --data-dir "$VOICES" + +echo ">> install user service" +mkdir -p "$UNIT_DIR" +install -m 0644 "$(dirname "$0")/piper.service" "$UNIT_DIR/piper.service" +systemctl --user daemon-reload +systemctl --user enable --now piper.service + +echo ">> wait + status" +sleep 2 +systemctl --user --no-pager --full status piper.service | head -n 6 || true + +echo ">> smoke test" +curl -sf -X POST localhost:5005/ \ + -H 'Content-Type: application/json' \ + -d "{\"text\":\"hello there\",\"voice\":\"$VOICE\"}" -o /tmp/piper-test.wav \ + && echo "OK: /tmp/piper-test.wav ($(stat -c%s /tmp/piper-test.wav) bytes)" \ + || { echo "smoke test FAILED"; journalctl --user -u piper.service --no-pager | tail -n 20; exit 1; } diff --git a/internal/config/config.go b/internal/config/config.go index cecbd2b..392f482 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,17 @@ type Config struct { LLMChatModel string // Ask Petal model; falls back to LLMModel if empty LLMTimeout time.Duration + // TTS (read-aloud). Off unless TTSEndpoint is set — when empty, the /api/tts + // route isn't mounted and the frontend falls back to the browser's Web Speech + // API. Endpoint points at a local Piper HTTP server. + TTSEndpoint string // Piper instance serving the English voice + TTSEndpointZH string // Piper instance serving the Chinese voice; empty = zh falls back to Web Speech + TTSVoiceEN string // Piper voice id for English (e.g. en_US-amy-medium) + TTSVoiceZH string // Piper voice id for Chinese (e.g. zh_CN-huayan-medium) + TTSCacheDir string // on-disk store for synthesized clips (content-addressed) + TTSTimeout time.Duration + TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg + // Auth (deferred — not wired in the local-dev build, kept for later) AuthentikURL string AuthentikClientID string @@ -41,6 +52,14 @@ func Load() *Config { LLMChatModel: env("LLM_CHAT_MODEL", ""), LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second), + TTSEndpoint: env("TTS_ENDPOINT", ""), + TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""), + TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"), + TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"), + TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"), + TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second), + TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"), + AuthentikURL: env("AUTHENTIK_URL", ""), AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""), AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""), diff --git a/internal/tts/handler.go b/internal/tts/handler.go new file mode 100644 index 0000000..c8863c1 --- /dev/null +++ b/internal/tts/handler.go @@ -0,0 +1,264 @@ +// Package tts implements read-aloud: it proxies short passages to a local Piper +// HTTP server (a fast, offline neural TTS), optionally transcodes Piper's WAV to +// mp3/opus with ffmpeg, and serves the audio back to the editor. Synthesized +// clips are content-addressed on disk so tapping the same word twice is instant +// and never re-synthesizes. The feature is entirely optional: when TTS_ENDPOINT +// is unset the route isn't mounted and the frontend falls back to the browser's +// Web Speech API. +package tts + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/go-chi/chi/v5" + + "gitea.parodia.dev/drwily/petal/internal/config" +) + +// maxTextBytes bounds a single synthesis request. Read-aloud is for a word or a +// sentence or two, not whole documents; this keeps one tap from pinning Piper. +const maxTextBytes = 4000 + +// lengthScale slows Piper slightly below natural pace — a learner following along +// with the words, mirroring the old utterance.rate = 0.95. Higher = slower. +const lengthScale = 1.1 + +// audioFormat describes one output encoding: the cache-file extension, the +// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin) +// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is". +type audioFormat struct { + ext string + contentType string + ffmpegArgs []string +} + +// formats maps the configured TTS_AUDIO_FORMAT to its encoding. mp3 is the safe +// default (every browser plays it); opus is ~half the size for speech but has +// patchier Safari support; wav skips ffmpeg entirely. +var formats = map[string]audioFormat{ + "wav": {ext: ".wav", contentType: "audio/wav"}, + "mp3": {ext: ".mp3", contentType: "audio/mpeg", + ffmpegArgs: []string{"-f", "mp3", "-c:a", "libmp3lame", "-b:a", "64k", "-ac", "1"}}, + "opus": {ext: ".opus", contentType: "audio/ogg", + ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}}, +} + +// route is the Piper instance and voice id serving one language. Each Piper +// HTTP server loads exactly one model, so distinct languages mean distinct +// endpoints (e.g. English on :5005, Chinese on :5006). +type route struct { + endpoint string // Piper HTTP base URL (no trailing slash) + voice string // Piper voice id (sent in the request; also part of the cache key) +} + +// Handler proxies synthesis to Piper and caches the result on disk. +type Handler struct { + routes map[string]route // base language (e.g. "en", "zh") -> Piper instance + cacheDir string + format audioFormat + client *http.Client +} + +// New builds a Handler from config. It returns (nil, false) when TTS_ENDPOINT is +// unset, signalling main to skip mounting the route. An unknown TTS_AUDIO_FORMAT +// falls back to mp3 rather than failing the whole server. +func New(cfg *config.Config) (*Handler, bool) { + if strings.TrimSpace(cfg.TTSEndpoint) == "" { + return nil, false + } + + format, ok := formats[strings.ToLower(strings.TrimSpace(cfg.TTSFormat))] + if !ok { + format = formats["mp3"] + } + + // Map by base language so en-US, en-GB, etc. all resolve to the English + // instance (the client sends BCP-47 tags like the old Web Speech path did). + // A language is only routable when both its endpoint and voice are set; + // otherwise the client falls back to Web Speech for that language. + routes := map[string]route{} + if cfg.TTSVoiceEN != "" { + routes["en"] = route{strings.TrimRight(cfg.TTSEndpoint, "/"), cfg.TTSVoiceEN} + } + if cfg.TTSEndpointZH != "" && cfg.TTSVoiceZH != "" { + routes["zh"] = route{strings.TrimRight(cfg.TTSEndpointZH, "/"), cfg.TTSVoiceZH} + } + + if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil { + // A missing cache dir isn't fatal — synthesis still works, it just won't + // cache. Disable the feature only on endpoint absence, not this. + fmt.Fprintf(os.Stderr, "tts: cache dir %q: %v\n", cfg.TTSCacheDir, err) + } + + return &Handler{ + routes: routes, + cacheDir: cfg.TTSCacheDir, + format: format, + client: &http.Client{Timeout: cfg.TTSTimeout}, + }, true +} + +// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is +// POST /api/tts. +func (h *Handler) Routes() chi.Router { + r := chi.NewRouter() + r.Post("/", h.synth) + return r +} + +// synthRequest is the body the editor posts: a passage and the BCP-47 language +// tag it's written in (e.g. "en-US", "zh-CN"). +type synthRequest struct { + Text string `json:"text"` + Lang string `json:"lang"` +} + +// synth resolves a voice for the requested language, returns cached audio when +// present, and otherwise asks Piper to synthesize, transcodes if configured, +// caches, and serves. An unconfigured language yields 404 so the client can fall +// back to Web Speech without treating it as an error. +func (h *Handler) synth(w http.ResponseWriter, r *http.Request) { + var req synthRequest + // Cap the raw body generously above maxTextBytes to leave room for JSON + // framing and the lang field; the text itself is truncated after decoding. + if err := json.NewDecoder(io.LimitReader(r.Body, maxTextBytes+1024)).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + text := strings.TrimSpace(req.Text) + if text == "" { + http.Error(w, "empty text", http.StatusBadRequest) + return + } + if len(text) > maxTextBytes { + // Trim back to the last valid rune boundary so we never hand Piper a + // half-encoded multibyte character (common with Chinese, 3 bytes/char). + text = text[:maxTextBytes] + for len(text) > 0 && !utf8.ValidString(text) { + text = text[:len(text)-1] + } + } + + rt, ok := h.routes[baseLang(req.Lang)] + if !ok { + // No voice for this language — let the client fall back to Web Speech. + http.Error(w, "no voice for language", http.StatusNotFound) + return + } + + // Content-addressed: identical (voice, text) → identical clip. The format + // extension keeps encodings from colliding in the same dir. + sum := sha256.Sum256([]byte(rt.voice + "\n" + text)) + name := hex.EncodeToString(sum[:])[:32] + h.format.ext + path := filepath.Join(h.cacheDir, name) + + if _, err := os.Stat(path); err == nil { + h.serve(w, r, path) + return + } + + audio, err := h.synthesize(r.Context(), rt, text) + if err != nil { + http.Error(w, "synthesis failed", http.StatusBadGateway) + fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err) + return + } + + // Best-effort cache write via a temp file + rename so a concurrent reader + // never sees a half-written clip. A failed write just means no caching. + if h.cacheDir != "" { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, audio, 0o644); err == nil { + _ = os.Rename(tmp, path) + } + } + + w.Header().Set("Content-Type", h.format.contentType) + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + _, _ = w.Write(audio) +} + +// serve streams a cached clip with a long-lived immutable cache header (the URL +// is content-addressed, so the bytes never change for a given request). +func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) { + w.Header().Set("Content-Type", h.format.contentType) + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + http.ServeFile(w, r, path) +} + +// synthesize POSTs to the route's Piper instance, then transcodes the returned +// WAV when the configured format calls for it. +func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte, error) { + body, _ := json.Marshal(map[string]any{ + "text": text, + "voice": rt.voice, + "length_scale": lengthScale, + }) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+"/", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := h.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return nil, fmt.Errorf("piper %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) + } + wav, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if h.format.ffmpegArgs == nil { + return wav, nil + } + return transcode(ctx, wav, h.format.ffmpegArgs) +} + +// transcode pipes WAV bytes through ffmpeg (stdin → stdout) into the target +// encoding. ffmpeg is assumed on PATH; an error here surfaces as a 502. +func transcode(ctx context.Context, wav []byte, args []string) ([]byte, error) { + // Guard ffmpeg against a hang independent of the HTTP client timeout. + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + full := append([]string{"-hide_banner", "-loglevel", "error", "-i", "pipe:0"}, args...) + full = append(full, "pipe:1") + cmd := exec.CommandContext(ctx, "ffmpeg", full...) + cmd.Stdin = bytes.NewReader(wav) + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg: %v: %s", err, strings.TrimSpace(errBuf.String())) + } + return out.Bytes(), nil +} + +// baseLang reduces a BCP-47 tag to its primary subtag, lowercased: "en-US" → "en", +// "zh-CN" → "zh", "" → "". Mirrors the client's pickVoice base-language matching. +func baseLang(tag string) string { + tag = strings.ToLower(strings.TrimSpace(tag)) + if i := strings.IndexAny(tag, "-_"); i >= 0 { + return tag[:i] + } + return tag +} diff --git a/internal/tts/handler_test.go b/internal/tts/handler_test.go new file mode 100644 index 0000000..00ee1ff --- /dev/null +++ b/internal/tts/handler_test.go @@ -0,0 +1,170 @@ +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))) + } +} diff --git a/web/src/audio/speech.ts b/web/src/audio/speech.ts index fd9bf66..f540e56 100644 --- a/web/src/audio/speech.ts +++ b/web/src/audio/speech.ts @@ -1,12 +1,42 @@ -// Read-aloud via the browser's Web Speech API — an offline pronunciation aid for -// an ESL writer: hear how an English word or passage sounds. No model, no -// network, no extra weight in the binary. Feature-detected so the buttons that -// use it can hide where speech isn't available. +// Read-aloud for an ESL writer: hear how an English word or passage sounds. +// +// Primary path is server-side neural TTS (Piper, proxied at /api/tts) — natural, +// consistent across devices, and far better than the browser's built-in voices +// (which fall back to robotic espeak on Chromium). If the server route is absent +// (TTS disabled) or unreachable, we fall back to the browser's Web Speech API so +// the buttons still do something. No model or network is strictly required. +// speechSupported reports whether read-aloud can do anything at all. Audio +// playback is universal, so as long as we can construct an Audio element OR the +// Web Speech API exists, the buttons should show. The server path is tried at +// call time and degrades on its own. export function speechSupported(): boolean { + if (typeof window === 'undefined') return false + return typeof window.Audio === 'function' || 'speechSynthesis' in window +} + +// webSpeechSupported gates the fallback path specifically. +function webSpeechSupported(): boolean { return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window } +// current holds the in-flight server-audio element and its object URL so a rapid +// re-tap can stop and replace it (mirroring speechSynthesis.cancel()). +let current: { audio: HTMLAudioElement; url: string } | null = null + +// requestSeq increments on every speak() call so a slow fetch that resolves after +// a newer tap can detect it's stale and bow out instead of double-playing. +let requestSeq = 0 + +function stopCurrent(): void { + if (current) { + current.audio.pause() + URL.revokeObjectURL(current.url) + current = null + } + if (webSpeechSupported()) window.speechSynthesis.cancel() +} + // pickVoice prefers an exact locale match (e.g. en-US), then any voice in the // same language family (en-*). Returns undefined to let the engine default. function pickVoice(lang: string): SpeechSynthesisVoice | undefined { @@ -15,12 +45,10 @@ function pickVoice(lang: string): SpeechSynthesisVoice | undefined { return voices.find((v) => v.lang === lang) || voices.find((v) => v.lang?.startsWith(base)) } -// speak reads `text` aloud, cancelling anything already in flight so rapid taps -// don't queue up. `lang` defaults to US English (the language she's learning); -// pass a zh locale to hear a Chinese passage. A touch slower than default so -// learners can follow along. -export function speak(text: string, lang = 'en-US'): void { - if (!speechSupported() || !text.trim()) return +// speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch +// slower than default so learners can follow along. +function speakWebSpeech(text: string, lang: string): void { + if (!webSpeechSupported()) return const synth = window.speechSynthesis synth.cancel() const utterance = new SpeechSynthesisUtterance(text) @@ -30,3 +58,47 @@ export function speak(text: string, lang = 'en-US'): void { utterance.rate = 0.95 synth.speak(utterance) } + +// speak reads `text` aloud, cancelling anything already in flight so rapid taps +// don't queue up. `lang` defaults to US English (the language she's learning); +// pass a zh locale to hear a Chinese passage. It tries the server's neural voice +// first and silently falls back to the browser voice if that's unavailable (route +// off, network error, or a 404 for a language with no configured voice). +export function speak(text: string, lang = 'en-US'): void { + if (!text.trim()) return + stopCurrent() + const seq = ++requestSeq + + fetch('/api/tts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, lang }), + }) + .then((res) => { + if (!res.ok) throw new Error(`tts ${res.status}`) + return res.blob() + }) + .then((blob) => { + // A newer tap superseded this one while the fetch was in flight — drop it. + if (seq !== requestSeq) return + const url = URL.createObjectURL(blob) + const audio = new Audio(url) + current = { audio, url } + // Release the blob once playback finishes (or errors) to avoid leaking. + const cleanup = () => { + if (current?.audio === audio) { + URL.revokeObjectURL(url) + current = null + } + } + audio.addEventListener('ended', cleanup) + audio.addEventListener('error', cleanup) + return audio.play() + }) + .catch(() => { + // Server TTS unavailable for this request — use the browser voice instead, + // unless a newer tap has already superseded this one. + if (seq !== requestSeq) return + speakWebSpeech(text, lang) + }) +}