5afa2dfb38
piper reads a Russian sentence with a Russian voice, and a Latin service id inside it comes out spelled, mangled or read as if it were a Russian word: "Vikunja", "SearXNG", "homesrv". The lever available is the text, so the dictionary maps a name to how it should be spelled for the voice to say it, and mavttsd applies it at the last edge before piper — every caller's text passes through that one point, and nothing upstream has to know how a name sounds. Data, not code. deploy/tts-lexicon.json ships 29 names; adding one needs a restart of mavttsd and no rebuild of the daemon that produced the text. Off unless -lexicon is set, like every other optional capability, and a path that is set and unreadable stops startup — saying names wrong in silence is the failure it exists to remove. Two details worth keeping: the alternation is sorted longest-first, or "Home Assistant" reads as "Хоум Assistant"; and the boundaries are written out rather than left to \b, which is ASCII-only and never fires next to a Cyrillic letter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
86 lines
2.9 KiB
Go
86 lines
2.9 KiB
Go
package tts
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestLexiconRewritesNames(t *testing.T) {
|
|
lex := NewLexicon(map[string]string{
|
|
"Vikunja": "Викунья",
|
|
"Home Assistant": "Хоум Ассистент",
|
|
"Home": "Хоум",
|
|
"GPU": "джи-пи-ю",
|
|
})
|
|
for _, tc := range []struct{ in, want string }{
|
|
{"задача в Vikunja готова", "задача в Викунья готова"},
|
|
// Case-insensitive: the router and the model both change the case of a
|
|
// name on the way through.
|
|
{"открой vikunja.", "открой Викунья."},
|
|
// Longest first, or "Home Assistant" is read as "Хоум Assistant".
|
|
{"Home Assistant не отвечает", "Хоум Ассистент не отвечает"},
|
|
// Two names in a row share the space between them, which one pass
|
|
// would consume.
|
|
{"GPU GPU", "джи-пи-ю джи-пи-ю"},
|
|
// Not a word boundary: a name inside a longer token is left alone.
|
|
{"vikunjaless", "vikunjaless"},
|
|
{"ничего не совпало", "ничего не совпало"},
|
|
{"", ""},
|
|
} {
|
|
if got := lex.Apply(tc.in); got != tc.want {
|
|
t.Errorf("Apply(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The zero value and an unconfigured path rewrite nothing, so a daemon with no
|
|
// dictionary behaves as it did before this existed.
|
|
func TestLexiconOffByDefault(t *testing.T) {
|
|
var zero *Lexicon
|
|
if got := zero.Apply("Vikunja"); got != "Vikunja" {
|
|
t.Errorf("nil lexicon rewrote %q", got)
|
|
}
|
|
lex, err := LoadLexicon("")
|
|
if err != nil {
|
|
t.Fatalf("LoadLexicon(\"\"): %v", err)
|
|
}
|
|
if lex.Size() != 0 || lex.Apply("Vikunja") != "Vikunja" {
|
|
t.Errorf("empty path produced a live lexicon of %d names", lex.Size())
|
|
}
|
|
}
|
|
|
|
// A path he set and that cannot be read is a startup failure. Saying names
|
|
// wrong in silence is what the dictionary exists to stop.
|
|
func TestLexiconLoadErrors(t *testing.T) {
|
|
if _, err := LoadLexicon(filepath.Join(t.TempDir(), "nope.json")); err == nil {
|
|
t.Error("a missing dictionary must be an error")
|
|
}
|
|
bad := filepath.Join(t.TempDir(), "bad.json")
|
|
if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LoadLexicon(bad); err == nil {
|
|
t.Error("an unparseable dictionary must be an error")
|
|
}
|
|
}
|
|
|
|
func TestLexiconRoundTripsAFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "lex.json")
|
|
if err := os.WriteFile(path, []byte(`{"Praxis":"Праксис"," ":"skipped","Nexus":""}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lex, err := LoadLexicon(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadLexicon: %v", err)
|
|
}
|
|
// Blank names and blank spellings are dropped: an entry that says nothing
|
|
// would delete the word it matched.
|
|
if lex.Size() != 1 {
|
|
t.Fatalf("Size = %d, want 1", lex.Size())
|
|
}
|
|
if got := lex.Apply("Praxis молчит"); got != "Праксис молчит" {
|
|
t.Errorf("Apply = %q", got)
|
|
}
|
|
}
|