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.
This commit is contained in:
+5
-1
@@ -200,7 +200,11 @@ func main() {
|
|||||||
// back to the browser's Web Speech API on its own.
|
// back to the browser's Web Speech API on its own.
|
||||||
if ttsHandler, ok := tts.New(cfg); ok {
|
if ttsHandler, ok := tts.New(cfg); ok {
|
||||||
pr.Mount("/tts", ttsHandler.Routes())
|
pr.Mount("/tts", ttsHandler.Routes())
|
||||||
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
|
// Name the languages, not just the English endpoint: which
|
||||||
|
// voices a deployment actually reached is the thing worth
|
||||||
|
// seeing at boot, and a missing sidecar is silent otherwise
|
||||||
|
// (a 404 the client answers by quietly using Web Speech).
|
||||||
|
log.Printf("read-aloud enabled (voices: %s)", strings.Join(ttsHandler.Languages(), ", "))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -579,6 +579,35 @@ Petal's env then carries `TTS_ENDPOINT=http://127.0.0.1:5005`,
|
|||||||
maps language → instance from config, so another language is another instance
|
maps language → instance from config, so another language is another instance
|
||||||
plus an env pair, no code change.
|
plus an env pair, no code change.
|
||||||
|
|
||||||
|
**Adding a language (Phase 21 made this literal).** Petal discovers its Piper
|
||||||
|
instances from the environment: English is the unsuffixed
|
||||||
|
`TTS_ENDPOINT`/`TTS_VOICE_EN`, and every other language is a
|
||||||
|
`TTS_ENDPOINT_<LANG>`/`TTS_VOICE_<LANG>` pair. `<LANG>` is the *base* tag —
|
||||||
|
`PT`, not `PT_PT`, because an environment variable name cannot hold a hyphen and
|
||||||
|
only one Portuguese model is loaded regardless. Both halves must be set: an
|
||||||
|
endpoint with no voice is dropped, so a half-finished language reads to the
|
||||||
|
browser as "no voice here, use Web Speech" instead of erroring on every tap. The
|
||||||
|
startup line names what it actually resolved:
|
||||||
|
|
||||||
|
```
|
||||||
|
read-aloud enabled (voices: en=en_US-amy-medium, pt=pt_PT-tugão-medium, zh=zh_CN-huayan-medium)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Portuguese: `pt_PT-tugão-medium` is the only European voice Piper ships.** The
|
||||||
|
other five `pt_*` models in the catalogue are all Brazilian, so the voice has to
|
||||||
|
be named explicitly for the same reason the Hunspell dictionary did (Phase 21):
|
||||||
|
the obvious default is the wrong country. Check what exists before assuming:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec petal-piper-en python -c "import urllib.request,json; \
|
||||||
|
d=json.load(urllib.request.urlopen('https://huggingface.co/rhasspy/piper-voices/resolve/main/voices.json')); \
|
||||||
|
print([k for k in d if k.startswith('pt')])"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Slow replay.** `POST /api/tts` takes `slow: true`, which raises Piper's
|
||||||
|
`length_scale` to about 4/3 (≈0.75× pace). It is a separate cache entry, not a
|
||||||
|
playback-rate trick, so the slow clip is synthesized once and then instant.
|
||||||
|
|
||||||
**Piper version note:** piper-tts moved synthesis from `POST /` to
|
**Piper version note:** piper-tts moved synthesis from `POST /` to
|
||||||
`POST /synthesize` in 1.6.0, with an identical request body. `TTS_PATH` selects
|
`POST /synthesize` in 1.6.0, with an identical request body. `TTS_PATH` selects
|
||||||
which — it defaults to `/`, and both the VPS compose and millenia's `start.sh`
|
which — it defaults to `/`, and both the VPS compose and millenia's `start.sh`
|
||||||
|
|||||||
@@ -49,8 +49,17 @@ LLM_TIMEOUT=90s
|
|||||||
# --- Read-aloud (Piper sidecars) ---------------------------------------------
|
# --- Read-aloud (Piper sidecars) ---------------------------------------------
|
||||||
# Endpoints are wired in docker-compose.yml; these pick the voice each sidecar
|
# Endpoints are wired in docker-compose.yml; these pick the voice each sidecar
|
||||||
# loads. Changing one means recreating that container so it downloads the model.
|
# loads. Changing one means recreating that container so it downloads the model.
|
||||||
|
#
|
||||||
|
# A language is routable only when both halves are set — a TTS_ENDPOINT_XX with
|
||||||
|
# no TTS_VOICE_XX reads as "no voice for this language" and the browser's own
|
||||||
|
# synthesizer takes over, rather than as an instance that errors on every
|
||||||
|
# request. Adding fr or es is a compose service plus a pair of lines here.
|
||||||
|
#
|
||||||
|
# pt_PT-tugão-medium is the only European Portuguese voice Piper ships; every
|
||||||
|
# other pt model in the catalogue is Brazilian.
|
||||||
TTS_VOICE_EN=en_US-amy-medium
|
TTS_VOICE_EN=en_US-amy-medium
|
||||||
TTS_VOICE_ZH=zh_CN-huayan-medium
|
TTS_VOICE_ZH=zh_CN-huayan-medium
|
||||||
|
TTS_VOICE_PT=pt_PT-tugão-medium
|
||||||
TTS_AUDIO_FORMAT=mp3
|
TTS_AUDIO_FORMAT=mp3
|
||||||
TTS_TIMEOUT=15s
|
TTS_TIMEOUT=15s
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ services:
|
|||||||
# separate containers; the handler maps language → instance from config.
|
# separate containers; the handler maps language → instance from config.
|
||||||
TTS_ENDPOINT: http://piper-en:5000
|
TTS_ENDPOINT: http://piper-en:5000
|
||||||
TTS_ENDPOINT_ZH: http://piper-zh:5000
|
TTS_ENDPOINT_ZH: http://piper-zh:5000
|
||||||
|
# A language is discovered from the TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG>
|
||||||
|
# pair, so fr and es cost a service and two lines rather than a code
|
||||||
|
# change. <LANG> is the base tag — an env var name can't hold pt-PT's
|
||||||
|
# hyphen, and there is one Portuguese voice loaded either way.
|
||||||
|
TTS_ENDPOINT_PT: http://piper-pt:5000
|
||||||
# The sidecars run piper-tts 1.6.0, which serves synthesis on
|
# The sidecars run piper-tts 1.6.0, which serves synthesis on
|
||||||
# /synthesize; millenia's older server keeps the default "/".
|
# /synthesize; millenia's older server keeps the default "/".
|
||||||
TTS_PATH: /synthesize
|
TTS_PATH: /synthesize
|
||||||
@@ -75,6 +80,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- piper-en
|
- piper-en
|
||||||
- piper-zh
|
- piper-zh
|
||||||
|
- piper-pt
|
||||||
labels:
|
labels:
|
||||||
traefik.enable: "true"
|
traefik.enable: "true"
|
||||||
traefik.docker.network: traefik
|
traefik.docker.network: traefik
|
||||||
@@ -123,6 +129,24 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
|
# European Portuguese, for the pt-PT pair. pt_PT-tugão-medium is the *only*
|
||||||
|
# European voice in Piper's catalogue — the other five Portuguese models are
|
||||||
|
# all pt_BR — so the default anyone reaches for is the Brazilian one, exactly
|
||||||
|
# as it was with the Hunspell dictionary in Phase 21. Named here rather than
|
||||||
|
# left to the image default for that reason.
|
||||||
|
piper-pt:
|
||||||
|
build:
|
||||||
|
context: deploy/piper
|
||||||
|
image: petal-piper:local
|
||||||
|
container_name: petal-piper-pt
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PIPER_VOICE: ${TTS_VOICE_PT:-pt_PT-tugão-medium}
|
||||||
|
volumes:
|
||||||
|
- piper-voices:/voices
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# Created and owned by the host's Traefik stack.
|
# Created and owned by the host's Traefik stack.
|
||||||
traefik:
|
traefik:
|
||||||
|
|||||||
+81
-12
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,10 +30,16 @@ type Config struct {
|
|||||||
// TTS (read-aloud). Off unless TTSEndpoint is set — when empty, the /api/tts
|
// 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
|
// route isn't mounted and the frontend falls back to the browser's Web Speech
|
||||||
// API. Endpoint points at a local Piper HTTP server.
|
// API. Endpoint points at a local Piper HTTP server.
|
||||||
TTSEndpoint string // Piper instance serving the English voice
|
TTSEndpoint string // Piper instance serving the English voice; also the on/off switch
|
||||||
TTSEndpointZH string // Piper instance serving the Chinese voice; empty = zh falls back to Web Speech
|
// TTSVoices is every language Petal can read aloud, keyed by base language
|
||||||
TTSVoiceEN string // Piper voice id for English (e.g. en_US-amy-medium)
|
// tag ("en", "zh", "pt", …). Each Piper server loads exactly one model, so
|
||||||
TTSVoiceZH string // Piper voice id for Chinese (e.g. zh_CN-huayan-medium)
|
// a language *is* an instance — and the instances are discovered from the
|
||||||
|
// environment rather than named in this struct: one
|
||||||
|
// TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair per language, so the fr and es
|
||||||
|
// pairs cost a compose service and two lines of .env rather than a code
|
||||||
|
// change. English keeps the unsuffixed TTS_ENDPOINT/TTS_VOICE_EN it has
|
||||||
|
// always had.
|
||||||
|
TTSVoices map[string]TTSVoice
|
||||||
// TTSPath is the path Piper serves synthesis on. Piper moved it from "/" to
|
// TTSPath is the path Piper serves synthesis on. Piper moved it from "/" to
|
||||||
// "/synthesize" in 1.6.0 with an unchanged request body, so this is a
|
// "/synthesize" in 1.6.0 with an unchanged request body, so this is a
|
||||||
// version knob, not a feature: millenia's older server keeps the default,
|
// version knob, not a feature: millenia's older server keeps the default,
|
||||||
@@ -56,6 +63,12 @@ type Config struct {
|
|||||||
AllowedSubs string
|
AllowedSubs string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TTSVoice is one Piper instance and the single voice it has loaded.
|
||||||
|
type TTSVoice struct {
|
||||||
|
Endpoint string
|
||||||
|
Voice string
|
||||||
|
}
|
||||||
|
|
||||||
// AuthEnabled reports whether real logins are configured. When false, Petal
|
// AuthEnabled reports whether real logins are configured. When false, Petal
|
||||||
// resolves every request to the local user.
|
// resolves every request to the local user.
|
||||||
func (c *Config) AuthEnabled() bool {
|
func (c *Config) AuthEnabled() bool {
|
||||||
@@ -77,14 +90,12 @@ 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", ""),
|
TTSEndpoint: env("TTS_ENDPOINT", ""),
|
||||||
TTSEndpointZH: env("TTS_ENDPOINT_ZH", ""),
|
TTSVoices: ttsVoices(os.Environ()),
|
||||||
TTSVoiceEN: env("TTS_VOICE_EN", "en_US-amy-medium"),
|
TTSPath: env("TTS_PATH", "/"),
|
||||||
TTSVoiceZH: env("TTS_VOICE_ZH", "zh_CN-huayan-medium"),
|
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
|
||||||
TTSPath: env("TTS_PATH", "/"),
|
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
|
||||||
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
|
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
|
||||||
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", ""),
|
||||||
@@ -93,6 +104,64 @@ func Load() *Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ttsVoices reads the Piper instances out of an environment slice (as returned
|
||||||
|
// by os.Environ) into a map keyed by base language tag.
|
||||||
|
//
|
||||||
|
// English is the unsuffixed pair, TTS_ENDPOINT + TTS_VOICE_EN, because that is
|
||||||
|
// what every deployment already sets and read-aloud has always been English
|
||||||
|
// first. Every other language is a TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair,
|
||||||
|
// discovered rather than enumerated — TTS_ENDPOINT_ZH is what millenia and the
|
||||||
|
// VPS already use, and TTS_ENDPOINT_PT is all the Portuguese pair needs.
|
||||||
|
//
|
||||||
|
// <LANG> is the *base* tag: an environment variable name cannot hold the hyphen
|
||||||
|
// in "pt-PT", and the handler routes on the base tag anyway (a request for
|
||||||
|
// pt-PT, pt-BR or bare pt reaches the same instance, because there is only one
|
||||||
|
// Portuguese voice loaded). A pair is ignored unless both halves are set: half
|
||||||
|
// a configuration should read as "no voice for this language" and fall back to
|
||||||
|
// the browser, not as an instance that answers every request with an error.
|
||||||
|
func ttsVoices(environ []string) map[string]TTSVoice {
|
||||||
|
vals := make(map[string]string, len(environ))
|
||||||
|
for _, kv := range environ {
|
||||||
|
if k, v, ok := strings.Cut(kv, "="); ok {
|
||||||
|
vals[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
voices := map[string]TTSVoice{}
|
||||||
|
add := func(lang, endpoint, voice string) {
|
||||||
|
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
|
||||||
|
voice = strings.TrimSpace(voice)
|
||||||
|
if endpoint == "" || voice == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
voices[lang] = TTSVoice{Endpoint: endpoint, Voice: voice}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two languages that shipped before this was a map keep their voice
|
||||||
|
// defaults, so an existing deployment that names only the endpoints (as
|
||||||
|
// millenia's unit does) sounds exactly as it did.
|
||||||
|
voiceOr := func(key, fallback string) string {
|
||||||
|
if v := strings.TrimSpace(vals[key]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
add("en", vals["TTS_ENDPOINT"], voiceOr("TTS_VOICE_EN", "en_US-amy-medium"))
|
||||||
|
for k, endpoint := range vals {
|
||||||
|
suffix, ok := strings.CutPrefix(k, "TTS_ENDPOINT_")
|
||||||
|
if !ok || suffix == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
voice := vals["TTS_VOICE_"+suffix]
|
||||||
|
if suffix == "ZH" {
|
||||||
|
voice = voiceOr("TTS_VOICE_ZH", "zh_CN-huayan-medium")
|
||||||
|
}
|
||||||
|
add(strings.ToLower(suffix), endpoint, voice)
|
||||||
|
}
|
||||||
|
return voices
|
||||||
|
}
|
||||||
|
|
||||||
func env(key, fallback string) string {
|
func env(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The Piper instances are discovered from the environment rather than named in
|
||||||
|
// code, so that a new pair costs a compose service and two .env lines. These
|
||||||
|
// assert the discovery rule, including the two shapes that already exist in the
|
||||||
|
// wild (millenia's systemd unit and the VPS compose file).
|
||||||
|
func TestTTSVoicesDiscovery(t *testing.T) {
|
||||||
|
voices := ttsVoices([]string{
|
||||||
|
"TTS_ENDPOINT=http://piper-en:5000",
|
||||||
|
"TTS_VOICE_EN=en_US-amy-medium",
|
||||||
|
"TTS_ENDPOINT_ZH=http://piper-zh:5000/",
|
||||||
|
"TTS_VOICE_ZH=zh_CN-huayan-medium",
|
||||||
|
"TTS_ENDPOINT_PT=http://piper-pt:5000",
|
||||||
|
"TTS_VOICE_PT=pt_PT-tugão-medium",
|
||||||
|
// Noise that must not become a language.
|
||||||
|
"TTS_PATH=/synthesize",
|
||||||
|
"PATH=/usr/bin",
|
||||||
|
})
|
||||||
|
|
||||||
|
want := map[string]TTSVoice{
|
||||||
|
"en": {"http://piper-en:5000", "en_US-amy-medium"},
|
||||||
|
// The trailing slash is trimmed here so the synthesis path concatenates
|
||||||
|
// cleanly rather than producing a double slash at every call site.
|
||||||
|
"zh": {"http://piper-zh:5000", "zh_CN-huayan-medium"},
|
||||||
|
"pt": {"http://piper-pt:5000", "pt_PT-tugão-medium"},
|
||||||
|
}
|
||||||
|
if len(voices) != len(want) {
|
||||||
|
t.Fatalf("discovered %v, want %v", voices, want)
|
||||||
|
}
|
||||||
|
for lang, w := range want {
|
||||||
|
if voices[lang] != w {
|
||||||
|
t.Errorf("%s = %+v, want %+v", lang, voices[lang], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half a configuration is not a language. An endpoint with no voice (or the
|
||||||
|
// reverse) must read as "no voice for this language" — a 404 the client answers
|
||||||
|
// by falling back to Web Speech — rather than as an instance that exists and
|
||||||
|
// errors on every request.
|
||||||
|
func TestTTSVoicesIgnoresHalfConfiguredLanguages(t *testing.T) {
|
||||||
|
voices := ttsVoices([]string{
|
||||||
|
"TTS_ENDPOINT=http://piper-en:5000",
|
||||||
|
"TTS_VOICE_EN=en_US-amy-medium",
|
||||||
|
"TTS_ENDPOINT_FR=http://piper-fr:5000", // no TTS_VOICE_FR
|
||||||
|
"TTS_VOICE_ES=es_ES-davefx-medium", // no TTS_ENDPOINT_ES
|
||||||
|
})
|
||||||
|
if _, ok := voices["fr"]; ok {
|
||||||
|
t.Errorf("fr routed with no voice configured")
|
||||||
|
}
|
||||||
|
if _, ok := voices["es"]; ok {
|
||||||
|
t.Errorf("es routed with no endpoint configured")
|
||||||
|
}
|
||||||
|
if len(voices) != 1 {
|
||||||
|
t.Errorf("discovered %v, want English only", voices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deployment that predates the map names only the endpoints and relies on the
|
||||||
|
// voice defaults; it must sound exactly as it did.
|
||||||
|
func TestTTSVoicesKeepsTheOriginalDefaults(t *testing.T) {
|
||||||
|
voices := ttsVoices([]string{
|
||||||
|
"TTS_ENDPOINT=http://127.0.0.1:5005",
|
||||||
|
"TTS_ENDPOINT_ZH=http://127.0.0.1:5006",
|
||||||
|
})
|
||||||
|
if got := voices["en"].Voice; got != "en_US-amy-medium" {
|
||||||
|
t.Errorf("en voice = %q, want the default", got)
|
||||||
|
}
|
||||||
|
if got := voices["zh"].Voice; got != "zh_CN-huayan-medium" {
|
||||||
|
t.Errorf("zh voice = %q, want the default", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-aloud is off when no English instance is configured; nothing else may
|
||||||
|
// switch it on. (tts.New gates on TTSEndpoint, so a stray TTS_ENDPOINT_PT with
|
||||||
|
// no English sibling must not produce a routable map that outlives that gate.)
|
||||||
|
func TestTTSVoicesEmptyWithoutEndpoints(t *testing.T) {
|
||||||
|
if voices := ttsVoices([]string{"TTS_VOICE_EN=en_US-amy-medium"}); len(voices) != 0 {
|
||||||
|
t.Errorf("discovered %v, want none", voices)
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-17
@@ -19,6 +19,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -36,6 +37,12 @@ const maxTextBytes = 4000
|
|||||||
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
|
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
|
||||||
const lengthScale = 1.1
|
const lengthScale = 1.1
|
||||||
|
|
||||||
|
// slowLengthScale is the "say it slower" replay (SUGGESTIONS §5e): roughly 0.75×
|
||||||
|
// the normal pace, which is the speed listening drills have used for decades.
|
||||||
|
// Piper stretches durations rather than resampling, so the voice keeps its pitch
|
||||||
|
// instead of turning into a slowed tape.
|
||||||
|
const slowLengthScale = lengthScale / 0.75
|
||||||
|
|
||||||
// audioFormat describes one output encoding: the cache-file extension, the
|
// audioFormat describes one output encoding: the cache-file extension, the
|
||||||
// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin)
|
// 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".
|
// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is".
|
||||||
@@ -105,16 +112,14 @@ func New(cfg *config.Config) (*Handler, bool) {
|
|||||||
format = formats["mp3"]
|
format = formats["mp3"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map by base language so en-US, en-GB, etc. all resolve to the English
|
// Keyed by base language so en-US, en-GB — and pt-PT, pt-BR, bare pt —
|
||||||
// instance (the client sends BCP-47 tags like the old Web Speech path did).
|
// resolve to the one instance that has that language's model loaded (the
|
||||||
// A language is only routable when both its endpoint and voice are set;
|
// client sends BCP-47 tags, as the old Web Speech path did). Config has
|
||||||
// otherwise the client falls back to Web Speech for that language.
|
// already dropped any language configured by halves, so an unroutable
|
||||||
|
// language reaches the client as a 404 and falls back to Web Speech.
|
||||||
routes := map[string]route{}
|
routes := map[string]route{}
|
||||||
if cfg.TTSVoiceEN != "" {
|
for lang, v := range cfg.TTSVoices {
|
||||||
routes["en"] = route{strings.TrimRight(cfg.TTSEndpoint, "/"), cfg.TTSVoiceEN}
|
routes[lang] = route{endpoint: v.Endpoint, voice: v.Voice}
|
||||||
}
|
|
||||||
if cfg.TTSEndpointZH != "" && cfg.TTSVoiceZH != "" {
|
|
||||||
routes["zh"] = route{strings.TrimRight(cfg.TTSEndpointZH, "/"), cfg.TTSVoiceZH}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
|
||||||
@@ -132,6 +137,19 @@ func New(cfg *config.Config) (*Handler, bool) {
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Languages lists the base language tags this handler can synthesize, sorted,
|
||||||
|
// each with the voice serving it — for the startup line, so a deployment says
|
||||||
|
// which sidecars it actually reached rather than which ones it was configured
|
||||||
|
// to want.
|
||||||
|
func (h *Handler) Languages() []string {
|
||||||
|
out := make([]string, 0, len(h.routes))
|
||||||
|
for lang, rt := range h.routes {
|
||||||
|
out = append(out, lang+"="+rt.voice)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
|
// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
|
||||||
// POST /api/tts.
|
// POST /api/tts.
|
||||||
func (h *Handler) Routes() chi.Router {
|
func (h *Handler) Routes() chi.Router {
|
||||||
@@ -140,11 +158,13 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// synthRequest is the body the editor posts: a passage and the BCP-47 language
|
// synthRequest is the body the editor posts: a passage, the BCP-47 language tag
|
||||||
// tag it's written in (e.g. "en-US", "zh-CN").
|
// it's written in (e.g. "en-US", "zh-CN", "pt-PT"), and whether to say it slowly
|
||||||
|
// — the replay a learner reaches for when the sentence went past too fast.
|
||||||
type synthRequest struct {
|
type synthRequest struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Lang string `json:"lang"`
|
Lang string `json:"lang"`
|
||||||
|
Slow bool `json:"slow"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// synth resolves a voice for the requested language, returns cached audio when
|
// synth resolves a voice for the requested language, returns cached audio when
|
||||||
@@ -180,9 +200,17 @@ func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content-addressed: identical (voice, text) → identical clip. The format
|
scale := lengthScale
|
||||||
// extension keeps encodings from colliding in the same dir.
|
if req.Slow {
|
||||||
sum := sha256.Sum256([]byte(rt.voice + "\n" + text))
|
scale = slowLengthScale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-addressed: identical (voice, pace, text) → identical clip. The pace
|
||||||
|
// belongs in the key — without it the slow replay of a word already heard at
|
||||||
|
// normal speed would be served from cache at normal speed, which is the one
|
||||||
|
// request where the difference is the whole point. The format extension keeps
|
||||||
|
// encodings from colliding in the same dir.
|
||||||
|
sum := sha256.Sum256([]byte(fmt.Sprintf("%s\n%.3f\n%s", rt.voice, scale, text)))
|
||||||
name := hex.EncodeToString(sum[:])[:32] + h.format.ext
|
name := hex.EncodeToString(sum[:])[:32] + h.format.ext
|
||||||
path := filepath.Join(h.cacheDir, name)
|
path := filepath.Join(h.cacheDir, name)
|
||||||
|
|
||||||
@@ -191,7 +219,7 @@ func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
audio, err := h.synthesize(r.Context(), rt, text)
|
audio, err := h.synthesize(r.Context(), rt, text, scale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
http.Error(w, "synthesis failed", http.StatusBadGateway)
|
||||||
fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
|
fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
|
||||||
@@ -222,11 +250,11 @@ func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {
|
|||||||
|
|
||||||
// synthesize POSTs to the route's Piper instance, then transcodes the returned
|
// synthesize POSTs to the route's Piper instance, then transcodes the returned
|
||||||
// WAV when the configured format calls for it.
|
// WAV when the configured format calls for it.
|
||||||
func (h *Handler) synthesize(ctx context.Context, rt route, text string) ([]byte, error) {
|
func (h *Handler) synthesize(ctx context.Context, rt route, text string, scale float64) ([]byte, error) {
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"text": text,
|
"text": text,
|
||||||
"voice": rt.voice,
|
"voice": rt.voice,
|
||||||
"length_scale": lengthScale,
|
"length_scale": scale,
|
||||||
})
|
})
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+h.synthURI, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+h.synthURI, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEc
|
|||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
last.voice, _ = req["voice"].(string)
|
last.voice, _ = req["voice"].(string)
|
||||||
last.text, _ = req["text"].(string)
|
last.text, _ = req["text"].(string)
|
||||||
|
last.scale, _ = req["length_scale"].(float64)
|
||||||
w.Header().Set("Content-Type", "audio/wav")
|
w.Header().Set("Content-Type", "audio/wav")
|
||||||
_, _ = w.Write(body)
|
_, _ = w.Write(body)
|
||||||
}))
|
}))
|
||||||
@@ -29,7 +30,10 @@ func newStubPiper(t *testing.T, body []byte) (*httptest.Server, *int32, *synthEc
|
|||||||
return srv, &calls, last
|
return srv, &calls, last
|
||||||
}
|
}
|
||||||
|
|
||||||
type synthEcho struct{ voice, text string }
|
type synthEcho struct {
|
||||||
|
voice, text string
|
||||||
|
scale float64
|
||||||
|
}
|
||||||
|
|
||||||
// newHandler builds a wav-format handler (no ffmpeg) pointed at a stub server.
|
// newHandler builds a wav-format handler (no ffmpeg) pointed at a stub server.
|
||||||
func newHandler(t *testing.T, endpoint string) *Handler {
|
func newHandler(t *testing.T, endpoint string) *Handler {
|
||||||
@@ -88,7 +92,12 @@ func TestSynthPathNormalisation(t *testing.T) {
|
|||||||
|
|
||||||
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
|
func post(t *testing.T, h *Handler, text, lang string) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, _ := json.Marshal(synthRequest{Text: text, Lang: lang})
|
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))
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(b))
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
h.synth(rr, req)
|
h.synth(rr, req)
|
||||||
@@ -192,8 +201,87 @@ func TestTextIsCapped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestBaseLang(t *testing.T) {
|
||||||
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "en": "en", "": ""}
|
cases := map[string]string{"en-US": "en", "EN_gb": "en", "zh-CN": "zh", "pt-PT": "pt", "en": "en", "": ""}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := baseLang(in); got != want {
|
if got := baseLang(in); got != want {
|
||||||
t.Errorf("baseLang(%q) = %q, want %q", in, got, want)
|
t.Errorf("baseLang(%q) = %q, want %q", in, got, want)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { nativeLang, speak, stopSpeech } from './speech'
|
||||||
|
import { resetPackForTests, setPackLang } from '../i18n'
|
||||||
|
|
||||||
|
// Read-aloud has two jobs beyond "make a sound": ask for the right pace, and ask
|
||||||
|
// in the right language. Both are decided at the call site and travel in the
|
||||||
|
// request body, so this checks the body — the part a component author can get
|
||||||
|
// wrong without anything failing loudly.
|
||||||
|
|
||||||
|
let bodies: Array<Record<string, unknown>>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bodies = []
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((_url: string, init: RequestInit) => {
|
||||||
|
bodies.push(JSON.parse(String(init.body)))
|
||||||
|
// Never resolves to audio: the fallback path needs no window.Audio here,
|
||||||
|
// and rejecting would run the Web Speech branch instead of the server one.
|
||||||
|
return new Promise(() => {})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stopSpeech()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
resetPackForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('speak', () => {
|
||||||
|
it('asks for the normal pace by default', () => {
|
||||||
|
speak('reception')
|
||||||
|
expect(bodies).toHaveLength(1)
|
||||||
|
expect(bodies[0]).toMatchObject({ text: 'reception', lang: 'en-US', slow: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for the slow replay when the slow control is used', () => {
|
||||||
|
speak('reception', undefined, true)
|
||||||
|
expect(bodies[0]).toMatchObject({ text: 'reception', slow: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still detects Chinese by script, so a zh selection is never read in English', () => {
|
||||||
|
speak('你好世界')
|
||||||
|
expect(bodies[0]).toMatchObject({ lang: 'zh-CN' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends nothing for empty text', () => {
|
||||||
|
speak(' ')
|
||||||
|
expect(bodies).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('nativeLang', () => {
|
||||||
|
// The voice for her own language comes from the pack, not from the letters.
|
||||||
|
// "comum" is spelled the same in both halves of the pt pair, so a detector
|
||||||
|
// would have to guess; the component that knows it is rendering her language
|
||||||
|
// says so instead.
|
||||||
|
it('follows the pair language', () => {
|
||||||
|
setPackLang('zh')
|
||||||
|
expect(nativeLang()).toBe('zh-CN')
|
||||||
|
setPackLang('pt-PT')
|
||||||
|
expect(nativeLang()).toBe('pt-PT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names a European Portuguese voice, never a Brazilian one', () => {
|
||||||
|
setPackLang('pt-PT')
|
||||||
|
expect(nativeLang()).not.toBe('pt-BR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is what a Latin-pair lookup speaks the other reading in', () => {
|
||||||
|
setPackLang('pt-PT')
|
||||||
|
speak('comum', nativeLang())
|
||||||
|
expect(bodies[0]).toMatchObject({ text: 'comum', lang: 'pt-PT' })
|
||||||
|
})
|
||||||
|
})
|
||||||
+26
-9
@@ -6,6 +6,8 @@
|
|||||||
// (TTS disabled) or unreachable, we fall back to the browser's Web Speech API so
|
// (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.
|
// the buttons still do something. No model or network is strictly required.
|
||||||
|
|
||||||
|
import { pack } from '../i18n'
|
||||||
|
|
||||||
// speechSupported reports whether read-aloud can do anything at all. Audio
|
// 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
|
// 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
|
// Web Speech API exists, the buttons should show. The server path is tried at
|
||||||
@@ -51,8 +53,10 @@ function pickVoice(lang: string): SpeechSynthesisVoice | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch
|
// speakWebSpeech is the fallback: the browser's built-in synthesizer. A touch
|
||||||
// slower than default so learners can follow along.
|
// slower than default so learners can follow along, and slower still when the
|
||||||
function speakWebSpeech(text: string, lang: string): void {
|
// slow replay was asked for — the fallback should degrade in voice quality, not
|
||||||
|
// in what the button does.
|
||||||
|
function speakWebSpeech(text: string, lang: string, slow: boolean): void {
|
||||||
if (!webSpeechSupported()) return
|
if (!webSpeechSupported()) return
|
||||||
const synth = window.speechSynthesis
|
const synth = window.speechSynthesis
|
||||||
synth.cancel()
|
synth.cancel()
|
||||||
@@ -60,7 +64,7 @@ function speakWebSpeech(text: string, lang: string): void {
|
|||||||
utterance.lang = lang
|
utterance.lang = lang
|
||||||
const voice = pickVoice(lang)
|
const voice = pickVoice(lang)
|
||||||
if (voice) utterance.voice = voice
|
if (voice) utterance.voice = voice
|
||||||
utterance.rate = 0.95
|
utterance.rate = slow ? 0.7 : 0.95
|
||||||
synth.speak(utterance)
|
synth.speak(utterance)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +78,26 @@ export function detectLang(text: string): string {
|
|||||||
return CJK.test(text) ? 'zh-CN' : 'en-US'
|
return CJK.test(text) ? 'zh-CN' : 'en-US'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nativeLang is the locale of the writer's own language — the voice for the
|
||||||
|
// *other* reading of a word that exists in both halves of a Latin pair.
|
||||||
|
//
|
||||||
|
// It is asked for explicitly rather than detected, and that is the point. A
|
||||||
|
// script boundary can be detected (the CJK test above); "comum" cannot. So the
|
||||||
|
// component that knows it is rendering her language says so, and everything
|
||||||
|
// rendering English lets the default stand. No guess, therefore no wrong guess
|
||||||
|
// about her writing — the same rule the both-directions gloss follows.
|
||||||
|
export function nativeLang(): string {
|
||||||
|
return pack().locale
|
||||||
|
}
|
||||||
|
|
||||||
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
|
// speak reads `text` aloud, cancelling anything already in flight so rapid taps
|
||||||
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
|
// don't queue up. `lang` defaults to a guess from the text (Chinese vs English)
|
||||||
// so callers can just pass the selection; pass an explicit locale to override.
|
// so callers can just pass the selection; pass an explicit locale to override.
|
||||||
// It tries the server's neural voice first and silently falls back to the browser
|
// `slow` asks for the stretched replay (SUGGESTIONS §5e) — the second tap on a
|
||||||
// voice if that's unavailable (route off, network error, or a 404 for a language
|
// sentence that went by too fast. It tries the server's neural voice first and
|
||||||
// with no configured voice).
|
// silently falls back to the browser voice if that's unavailable (route off,
|
||||||
export function speak(text: string, lang = detectLang(text)): void {
|
// network error, or a 404 for a language with no configured voice).
|
||||||
|
export function speak(text: string, lang = detectLang(text), slow = false): void {
|
||||||
if (!text.trim()) return
|
if (!text.trim()) return
|
||||||
stopSpeech()
|
stopSpeech()
|
||||||
const seq = ++requestSeq
|
const seq = ++requestSeq
|
||||||
@@ -88,7 +105,7 @@ export function speak(text: string, lang = detectLang(text)): void {
|
|||||||
fetch('/api/tts', {
|
fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text, lang }),
|
body: JSON.stringify({ text, lang, slow }),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`tts ${res.status}`)
|
if (!res.ok) throw new Error(`tts ${res.status}`)
|
||||||
@@ -115,6 +132,6 @@ export function speak(text: string, lang = detectLang(text)): void {
|
|||||||
// Server TTS unavailable for this request — use the browser voice instead,
|
// Server TTS unavailable for this request — use the browser voice instead,
|
||||||
// unless a newer tap has already superseded this one.
|
// unless a newer tap has already superseded this one.
|
||||||
if (seq !== requestSeq) return
|
if (seq !== requestSeq) return
|
||||||
speakWebSpeech(text, lang)
|
speakWebSpeech(text, lang, slow)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1109,6 +1109,7 @@ export function EditorCore({
|
|||||||
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
|
||||||
onRewrite={handleRewrite}
|
onRewrite={handleRewrite}
|
||||||
onSpeak={speechSupported() ? () => speak(selection.text) : null}
|
onSpeak={speechSupported() ? () => speak(selection.text) : null}
|
||||||
|
onSpeakSlow={speechSupported() ? () => speak(selection.text, undefined, true) : null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{rewrite && (
|
{rewrite && (
|
||||||
|
|||||||
@@ -30,11 +30,15 @@ interface Props {
|
|||||||
// Read the selected text aloud (null when speech isn't available — the button
|
// Read the selected text aloud (null when speech isn't available — the button
|
||||||
// is then hidden).
|
// is then hidden).
|
||||||
onSpeak: (() => void) | null
|
onSpeak: (() => void) | null
|
||||||
|
// The same passage, said slowly. A whole sentence replayed at three-quarter
|
||||||
|
// speed is the case SUGGESTIONS §5e is actually about — a word she can look
|
||||||
|
// up, but a sentence only goes past once.
|
||||||
|
onSpeakSlow: (() => void) | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||||
|
|
||||||
export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
export function SelectionBubble({ style, onRewrite, onSpeak, onSpeakSlow }: Props) {
|
||||||
const pk = usePack()
|
const pk = usePack()
|
||||||
const [natural, ...tones] = REWRITE_STYLES
|
const [natural, ...tones] = REWRITE_STYLES
|
||||||
|
|
||||||
@@ -85,6 +89,20 @@ export function SelectionBubble({ style, onRewrite, onSpeak }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onSpeakSlow && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
|
||||||
|
onClick={onSpeakSlow}
|
||||||
|
className="inline-flex h-8 items-center justify-center px-2 text-sm"
|
||||||
|
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)', pointerEvents: 'auto' }}
|
||||||
|
title={pk.editor.readSlowly}
|
||||||
|
aria-label="Read selection aloud slowly"
|
||||||
|
>
|
||||||
|
🐢
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
|
||||||
|
|
||||||
{tones.map((t) => (
|
{tones.map((t) => (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { WordInfo } from '../../api/client'
|
import type { WordInfo } from '../../api/client'
|
||||||
import { speak, speechSupported } from '../../audio/speech'
|
import { nativeLang, speak, speechSupported } from '../../audio/speech'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
import { wordBand } from './wordband'
|
import { wordBand } from './wordband'
|
||||||
|
|
||||||
@@ -77,16 +77,32 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{speechSupported() && (
|
{speechSupported() && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => speak(word)}
|
type="button"
|
||||||
aria-label={`Pronounce ${word}`}
|
onClick={() => speak(word)}
|
||||||
title={t.editor.readAloud}
|
aria-label={`Pronounce ${word}`}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
title={t.editor.readAloud}
|
||||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||||
>
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
🔊
|
>
|
||||||
</button>
|
🔊
|
||||||
|
</button>
|
||||||
|
{/* The same word, stretched out. A learner replaying a word at
|
||||||
|
three-quarter speed is one of the oldest listening aids there
|
||||||
|
is, and Piper does it by lengthening durations rather than
|
||||||
|
slowing the tape, so it stays a voice rather than a groan. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => speak(word, undefined, true)}
|
||||||
|
aria-label={`Pronounce ${word} slowly`}
|
||||||
|
title={t.editor.readSlowly}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
🐢
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,9 +213,27 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
|||||||
className="mt-3 rounded-xl px-2.5 py-2"
|
className="mt-3 rounded-xl px-2.5 py-2"
|
||||||
style={{ background: 'var(--color-surface-alt)' }}
|
style={{ background: 'var(--color-surface-alt)' }}
|
||||||
>
|
>
|
||||||
<p className="mb-1 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
{t.editor.alsoIn}
|
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
|
||||||
</p>
|
{t.editor.alsoIn}
|
||||||
|
</p>
|
||||||
|
{/* Her language, in her language's voice. The pack names the locale
|
||||||
|
(nativeLang) rather than anything guessing from the letters:
|
||||||
|
"comum" is spelled the same either way, and an English voice
|
||||||
|
reading it is the mistake this whole block exists to avoid. */}
|
||||||
|
{speechSupported() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => speak(word, nativeLang())}
|
||||||
|
aria-label={`Pronounce ${word} in ${t.nativeName}`}
|
||||||
|
title={t.editor.readAloudNative}
|
||||||
|
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||||
|
style={{ background: 'var(--color-surface)', color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||||
{reverse.gloss || word}
|
{reverse.gloss || word}
|
||||||
{reverse.phonetic && (
|
{reverse.phonetic && (
|
||||||
|
|||||||
@@ -445,15 +445,29 @@ function ReviewSession({
|
|||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<span className="text-lg font-extrabold text-plum">{card.word}</span>
|
<span className="text-lg font-extrabold text-plum">{card.word}</span>
|
||||||
{speechSupported() && (
|
{speechSupported() && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => speak(card.word)}
|
type="button"
|
||||||
aria-label={`Pronounce ${card.word}`}
|
onClick={() => speak(card.word)}
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
aria-label={`Pronounce ${card.word}`}
|
||||||
style={{ background: 'var(--color-surface)' }}
|
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||||
>
|
style={{ background: 'var(--color-surface)' }}
|
||||||
🔊
|
>
|
||||||
</button>
|
🔊
|
||||||
|
</button>
|
||||||
|
{/* A word she has just failed to recall is exactly the word
|
||||||
|
worth hearing stretched out. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => speak(card.word, undefined, true)}
|
||||||
|
aria-label={`Pronounce ${card.word} slowly`}
|
||||||
|
title={t.garden.readSlowly}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||||
|
style={{ background: 'var(--color-surface)' }}
|
||||||
|
>
|
||||||
|
🐢
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{card.phonetic && (
|
{card.phonetic && (
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ describe('the zh pack', () => {
|
|||||||
expect(empties).toEqual([])
|
expect(empties).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The voice read-aloud speaks this pair in. A pack that names a locale no
|
||||||
|
// Piper voice exists for degrades to Web Speech, which is survivable; a pack
|
||||||
|
// that names the *wrong region* does not announce itself at all — it just
|
||||||
|
// reads her language back to her in the accent the pair exists to avoid.
|
||||||
|
it.each(PACKS)('names a speakable locale for its own language ($code)', (p) => {
|
||||||
|
expect(p.locale, `${p.code} has no locale`).toMatch(/^[a-z]{2}(-[A-Za-z]{2,4})?$/)
|
||||||
|
expect(p.locale.split('-')[0]).toBe(p.code.split('-')[0])
|
||||||
|
if (p.code === 'pt-PT') expect(p.locale).toBe('pt-PT') // never pt-BR
|
||||||
|
})
|
||||||
|
|
||||||
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
||||||
const { COMPANIONS } = await import('../components/Companion/companions')
|
const { COMPANIONS } = await import('../components/Companion/companions')
|
||||||
for (const c of COMPANIONS) {
|
for (const c of COMPANIONS) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type { Pack } from '../types'
|
|||||||
export const ptPT: Pack = {
|
export const ptPT: Pack = {
|
||||||
code: 'pt-PT',
|
code: 'pt-PT',
|
||||||
nativeName: 'Português',
|
nativeName: 'Português',
|
||||||
|
locale: 'pt-PT',
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
duplicateTitle: (title) => `${title} (cópia)`,
|
duplicateTitle: (title) => `${title} (cópia)`,
|
||||||
@@ -184,6 +185,8 @@ export const ptPT: Pack = {
|
|||||||
inGarden: 'Já está no jardim · In your garden (tap to remove)',
|
inGarden: 'Já está no jardim · In your garden (tap to remove)',
|
||||||
saveToGarden: 'Guardar no jardim · Save to garden',
|
saveToGarden: 'Guardar no jardim · Save to garden',
|
||||||
readAloud: 'Ler em voz alta · Read aloud',
|
readAloud: 'Ler em voz alta · Read aloud',
|
||||||
|
readSlowly: 'Ler devagar · Read slowly',
|
||||||
|
readAloudNative: 'Ler em português · Read in Portuguese',
|
||||||
lookingUp: 'A procurar… · Looking up…',
|
lookingUp: 'A procurar… · Looking up…',
|
||||||
definition: 'Definição · Definition',
|
definition: 'Definição · Definition',
|
||||||
synonyms: 'Sinónimos · Synonyms',
|
synonyms: 'Sinónimos · Synonyms',
|
||||||
@@ -242,6 +245,7 @@ export const ptPT: Pack = {
|
|||||||
due: 'a rever · due',
|
due: 'a rever · due',
|
||||||
seen: (reps, intervalDays) => `${reps}× revista · seen ${reps}× · intervalo ${intervalDays}d`,
|
seen: (reps, intervalDays) => `${reps}× revista · seen ${reps}× · intervalo ${intervalDays}d`,
|
||||||
readAloud: '🔊 Ler',
|
readAloud: '🔊 Ler',
|
||||||
|
readSlowly: '🐢 Devagar',
|
||||||
source: '📄 Origem · Source',
|
source: '📄 Origem · Source',
|
||||||
remove: '🗑 Remover',
|
remove: '🗑 Remover',
|
||||||
growing: (n) => `🐱💤 ${n} flor${n === 1 ? '' : 'es'} no jardim · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
growing: (n) => `🐱💤 ${n} flor${n === 1 ? '' : 'es'} no jardim · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { Pack } from '../types'
|
|||||||
export const zh: Pack = {
|
export const zh: Pack = {
|
||||||
code: 'zh',
|
code: 'zh',
|
||||||
nativeName: '中文',
|
nativeName: '中文',
|
||||||
|
locale: 'zh-CN',
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
duplicateTitle: (title) => `${title} (副本)`,
|
duplicateTitle: (title) => `${title} (副本)`,
|
||||||
@@ -169,6 +170,8 @@ export const zh: Pack = {
|
|||||||
inGarden: '已在词汇花园 · In your garden (tap to remove)',
|
inGarden: '已在词汇花园 · In your garden (tap to remove)',
|
||||||
saveToGarden: '加入词汇花园 · Save to garden',
|
saveToGarden: '加入词汇花园 · Save to garden',
|
||||||
readAloud: '朗读 · Read aloud',
|
readAloud: '朗读 · Read aloud',
|
||||||
|
readSlowly: '慢速朗读 · Read slowly',
|
||||||
|
readAloudNative: '用中文朗读 · Read in Chinese',
|
||||||
lookingUp: '查找中… · Looking up…',
|
lookingUp: '查找中… · Looking up…',
|
||||||
definition: '释义 · Definition',
|
definition: '释义 · Definition',
|
||||||
synonyms: '近义词 · Synonyms',
|
synonyms: '近义词 · Synonyms',
|
||||||
@@ -228,6 +231,7 @@ export const zh: Pack = {
|
|||||||
due: '待复习 · due',
|
due: '待复习 · due',
|
||||||
seen: (reps, intervalDays) => `复习 ${reps} 次 · seen ${reps}× · 间隔 ${intervalDays}d`,
|
seen: (reps, intervalDays) => `复习 ${reps} 次 · seen ${reps}× · 间隔 ${intervalDays}d`,
|
||||||
readAloud: '🔊 朗读',
|
readAloud: '🔊 朗读',
|
||||||
|
readSlowly: '🐢 慢速',
|
||||||
source: '📄 出处 · Source',
|
source: '📄 出处 · Source',
|
||||||
remove: '🗑 移除',
|
remove: '🗑 移除',
|
||||||
growing: (n) => `🐱💤 ${n} 朵花在花园里 · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
growing: (n) => `🐱💤 ${n} 朵花在花园里 · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export interface Pack {
|
|||||||
// for anywhere Petal has to say which pair this is.
|
// for anywhere Petal has to say which pair this is.
|
||||||
code: PairLang
|
code: PairLang
|
||||||
nativeName: string
|
nativeName: string
|
||||||
|
// The BCP-47 locale to *speak* this language in — what read-aloud sends to
|
||||||
|
// Piper (and to the browser's Web Speech fallback). It is not derivable from
|
||||||
|
// `code`: zh is a pair language but zh-CN is a voice, and a pack is the only
|
||||||
|
// place that knows which regional voice its pair should be read in. pt-PT is
|
||||||
|
// spelled out for the same reason the prompts spell it out — the default
|
||||||
|
// Portuguese voice anyone reaches for is Brazilian.
|
||||||
|
locale: string
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
// A duplicated document's title. A function, not a suffix: where the marker
|
// A duplicated document's title. A function, not a suffix: where the marker
|
||||||
@@ -129,6 +136,13 @@ export interface Pack {
|
|||||||
inGarden: string
|
inGarden: string
|
||||||
saveToGarden: string
|
saveToGarden: string
|
||||||
readAloud: string
|
readAloud: string
|
||||||
|
// The same passage, said slowly (SUGGESTIONS §5e). Only ever offered for
|
||||||
|
// English: it is the language she is learning to hear.
|
||||||
|
readSlowly: string
|
||||||
|
// Read the *other* reading aloud — the one in her own language, in her own
|
||||||
|
// language's voice. Sits on the `alsoIn` block, so a pack whose pair has no
|
||||||
|
// collisions never sees it rendered.
|
||||||
|
readAloudNative: string
|
||||||
lookingUp: string
|
lookingUp: string
|
||||||
definition: string
|
definition: string
|
||||||
synonyms: string
|
synonyms: string
|
||||||
@@ -170,6 +184,9 @@ export interface Pack {
|
|||||||
due: string
|
due: string
|
||||||
seen: (reps: number, intervalDays: number) => string
|
seen: (reps: number, intervalDays: number) => string
|
||||||
readAloud: string
|
readAloud: string
|
||||||
|
// Short label for the slow replay on a flashcard, where a word she is
|
||||||
|
// trying to recall is exactly the word worth hearing stretched out.
|
||||||
|
readSlowly: string
|
||||||
source: string
|
source: string
|
||||||
remove: string
|
remove: string
|
||||||
growing: (n: number) => string
|
growing: (n: number) => string
|
||||||
|
|||||||
Reference in New Issue
Block a user