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
This commit is contained in:
14
.env.example
14
.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_CHAT_MODEL= # Ask Petal model (Mandarin-native). Falls back to LLM_MODEL if empty.
|
||||||
LLM_TIMEOUT=30s
|
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) ---
|
# --- Deferred (not wired in the local-dev build) ---
|
||||||
|
|
||||||
# Auth (Authentik OIDC) — deferred; single hardcoded local user for now
|
# Auth (Authentik OIDC) — deferred; single hardcoded local user for now
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/tts"
|
||||||
"gitea.parodia.dev/drwily/petal/web"
|
"gitea.parodia.dev/drwily/petal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,6 +89,14 @@ func main() {
|
|||||||
log.Fatalf("image store: %v", err)
|
log.Fatalf("image store: %v", err)
|
||||||
}
|
}
|
||||||
api.Mount("/images", imgHandler.Routes())
|
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).
|
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||||
|
|||||||
65
deploy/README.md
Normal file
65
deploy/README.md
Normal file
@@ -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).
|
||||||
17
deploy/piper-zh.service
Normal file
17
deploy/piper-zh.service
Normal file
@@ -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
|
||||||
17
deploy/piper.service
Normal file
17
deploy/piper.service
Normal file
@@ -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
|
||||||
43
deploy/setup-piper.sh
Executable file
43
deploy/setup-piper.sh
Executable file
@@ -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; }
|
||||||
@@ -20,6 +20,17 @@ type Config struct {
|
|||||||
LLMChatModel string // Ask Petal model; falls back to LLMModel if empty
|
LLMChatModel string // Ask Petal model; falls back to LLMModel if empty
|
||||||
LLMTimeout time.Duration
|
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)
|
// Auth (deferred — not wired in the local-dev build, kept for later)
|
||||||
AuthentikURL string
|
AuthentikURL string
|
||||||
AuthentikClientID string
|
AuthentikClientID string
|
||||||
@@ -41,6 +52,14 @@ func Load() *Config {
|
|||||||
LLMChatModel: env("LLM_CHAT_MODEL", ""),
|
LLMChatModel: env("LLM_CHAT_MODEL", ""),
|
||||||
LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second),
|
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", ""),
|
AuthentikURL: env("AUTHENTIK_URL", ""),
|
||||||
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
|
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
|
||||||
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
|
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
|
||||||
|
|||||||
264
internal/tts/handler.go
Normal file
264
internal/tts/handler.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
170
internal/tts/handler_test.go
Normal file
170
internal/tts/handler_test.go
Normal file
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,42 @@
|
|||||||
// Read-aloud via the browser's Web Speech API — an offline pronunciation aid for
|
// Read-aloud for an ESL writer: hear how an English word or passage sounds.
|
||||||
// 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
|
// Primary path is server-side neural TTS (Piper, proxied at /api/tts) — natural,
|
||||||
// use it can hide where speech isn't available.
|
// 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 {
|
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
|
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
|
// 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.
|
// same language family (en-*). Returns undefined to let the engine default.
|
||||||
function pickVoice(lang: string): SpeechSynthesisVoice | undefined {
|
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))
|
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
|
// speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch
|
||||||
// don't queue up. `lang` defaults to US English (the language she's learning);
|
// slower than default so learners can follow along.
|
||||||
// pass a zh locale to hear a Chinese passage. A touch slower than default so
|
function speakWebSpeech(text: string, lang: string): void {
|
||||||
// learners can follow along.
|
if (!webSpeechSupported()) return
|
||||||
export function speak(text: string, lang = 'en-US'): void {
|
|
||||||
if (!speechSupported() || !text.trim()) return
|
|
||||||
const synth = window.speechSynthesis
|
const synth = window.speechSynthesis
|
||||||
synth.cancel()
|
synth.cancel()
|
||||||
const utterance = new SpeechSynthesisUtterance(text)
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
@@ -30,3 +58,47 @@ export function speak(text: string, lang = 'en-US'): void {
|
|||||||
utterance.rate = 0.95
|
utterance.rate = 0.95
|
||||||
synth.speak(utterance)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user