diff --git a/cmd/mavttsd/main.go b/cmd/mavttsd/main.go index 88e3898..2f0ba7c 100644 --- a/cmd/mavttsd/main.go +++ b/cmd/mavttsd/main.go @@ -23,6 +23,7 @@ import ( "syscall" "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/tts" "github.com/kami/maven/internal/worker" ) @@ -39,15 +40,27 @@ func run(args []string) error { model := flag.String("model", "", "path to piper onnx model file") espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory") tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model") + lexiconPath := flag.String("lexicon", "", "path to the pronunciation dictionary (json, name to spelling)") flag.CommandLine.Parse(args) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer stop() + // Read before the handler is built: a dictionary he asked for and that + // cannot be read is a startup failure, not a warning. Saying names wrong + // in silence is the thing it exists to stop. + lex, err := tts.LoadLexicon(*lexiconPath) + if err != nil { + return err + } + if lex.Size() > 0 { + log.Printf("mavttsd: pronunciation dictionary: %d names from %s", lex.Size(), *lexiconPath) + } + var s worker.Synthesizer if *piperBin != "" && *model != "" { - s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel) + s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel, lex) log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model) } else { log.Printf("mavttsd: no piper/model specified, using stub handler") diff --git a/cmd/mavttsd/main_test.go b/cmd/mavttsd/main_test.go index 2b42cdf..07b9b00 100644 --- a/cmd/mavttsd/main_test.go +++ b/cmd/mavttsd/main_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/tts" "github.com/kami/maven/internal/worker" ) @@ -117,3 +118,18 @@ func abs(n int) int { } return n } + +// The dictionary that ships in deploy/ must parse and must be non-empty. It is +// data, so nothing else would catch a trailing comma before the voice did. +func TestShippedLexiconLoads(t *testing.T) { + lex, err := tts.LoadLexicon(filepath.Join("..", "..", "deploy", "tts-lexicon.json")) + if err != nil { + t.Fatalf("deploy/tts-lexicon.json: %v", err) + } + if lex.Size() < 10 { + t.Errorf("shipped dictionary holds %d names, want the full list", lex.Size()) + } + if got := lex.Apply("задача в Vikunja"); got == "задача в Vikunja" { + t.Error("the shipped dictionary did not rewrite a name it lists") + } +} diff --git a/cmd/mavttsd/piper_handler.go b/cmd/mavttsd/piper_handler.go index 6a39b0e..a80a618 100644 --- a/cmd/mavttsd/piper_handler.go +++ b/cmd/mavttsd/piper_handler.go @@ -9,6 +9,7 @@ import ( "os/exec" "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/tts" "github.com/kami/maven/internal/worker" ) @@ -18,15 +19,20 @@ type piperHandler struct { configPath string espeakData string tashkeelModel string + // lexicon rewrites service ids and Latin names into the spelling the + // Russian voice reads correctly (Vikunja #458). Nil-safe: an unconfigured + // dictionary rewrites nothing. + lexicon *tts.Lexicon } -func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string) *piperHandler { +func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string, lexicon *tts.Lexicon) *piperHandler { return &piperHandler{ piperPath: piperPath, modelPath: modelPath, configPath: modelPath + ".json", espeakData: espeakData, tashkeelModel: tashkeelModel, + lexicon: lexicon, } } @@ -66,7 +72,14 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq) return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err) } - if _, err := io.WriteString(stdin, req.Text); err != nil { + // The dictionary is applied here, at the last edge before the voice: every + // caller's text passes through this one point, and nothing upstream has to + // know how a name is spelled out loud. + text := req.Text + if h.lexicon != nil { + text = h.lexicon.Apply(text) + } + if _, err := io.WriteString(stdin, text); err != nil { stdin.Close() stdout.Close() _ = cmd.Wait() diff --git a/deploy/tts-lexicon.json b/deploy/tts-lexicon.json new file mode 100644 index 0000000..26526e0 --- /dev/null +++ b/deploy/tts-lexicon.json @@ -0,0 +1,30 @@ +{ + "Maven": "Мэйвен", + "Nexus": "Нексус", + "Praxis": "Праксис", + "Hexis": "Хексис", + "Vikunja": "Викунья", + "SearXNG": "сёрчиксэнджи", + "Kiwix": "Кивикс", + "Gitea": "Гитея", + "Home Assistant": "Хоум Ассистент", + "Docker": "Докер", + "Telegram": "Телеграм", + "ntfy": "энтифай", + "homesrv": "хоумсёрв", + "workpc": "воркписи", + "whisper": "виспер", + "piper": "пайпер", + "llama-server": "лама сервер", + "Qwen": "Квен", + "CalDAV": "калдав", + "IMAP": "аймап", + "API": "эй-пи-ай", + "CPU": "си-пи-ю", + "GPU": "джи-пи-ю", + "RAM": "оперативная память", + "SSD": "эс-эс-ди", + "uptime": "аптайм", + "backup": "бэкап", + "deploy": "деплой" +} diff --git a/docs/operations.md b/docs/operations.md index 004f54d..4d7fb4c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -90,6 +90,13 @@ export LD_LIBRARY_PATH="$ROOT/deps/piper" Without `-piper` it runs as a stub. +`-lexicon deploy/tts-lexicon.json` adds the pronunciation dictionary: a flat +JSON object of name to Russian spelling, applied to the text just before piper +reads it. It is how `Vikunja` is said as a word rather than spelled out, and how +`SearXNG` and `homesrv` are said at all. Off unless the flag is set; a path that +is set and unreadable stops mavttsd rather than letting it say names wrong in +silence. Adding a name needs a restart of mavttsd and nothing else. + ## mavweb — PWA voice bridge (WebSocket ↔ TCP) No CGo, no deps; builds with stock Go. diff --git a/internal/tts/lexicon.go b/internal/tts/lexicon.go new file mode 100644 index 0000000..df273de --- /dev/null +++ b/internal/tts/lexicon.go @@ -0,0 +1,128 @@ +package tts + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// Pronunciation dictionary (Vikunja #458). +// +// piper reads a Russian sentence with a Russian voice, and a Latin service id +// inside that sentence comes out as letters or as noise: "Vikunja" is spelled +// out, "SearXNG" is unreadable, and "homesrv" is read as if it were a word. The +// fix is not a code change per name — it is a file of replacements applied to +// the text before piper sees it. +// +// Spelling, not phonemes. piper has no lexicon input of its own here, so the +// only lever is the text, and the entry for a name is how it should be spelled +// in Russian for the voice to say it right. That also means a wrong entry is +// visible: it is a word, and it is read out loud. +// +// The dictionary is data, so it ships as a file rather than a table in Go. A +// name added to it needs no rebuild and no deploy of the daemon that owns the +// text — only a restart of mavttsd, which is the process that reads it. + +// Lexicon rewrites names into the spelling the voice reads correctly. +// +// The zero value is usable and rewrites nothing, so a daemon with no dictionary +// configured behaves exactly as it did before this existed. +type Lexicon struct { + // Rules are held in one alternation rather than as a map, so a text is + // scanned once however many entries there are, and the longest name wins + // where two overlap ("Home Assistant" before "Home"). + re *regexp.Regexp + // by lower-cased name, because the match is case-insensitive and the + // replacement is not derived from what was matched. + by map[string]string +} + +// LoadLexicon reads a dictionary file: a flat JSON object of name to spelling. +// +// {"Vikunja": "Викунья", "SearXNG": "сёрчиксэнджи"} +// +// An empty path returns an empty Lexicon and no error — the dictionary is off +// unless configured, like every other optional capability. A path that is set +// and unreadable IS an error: he asked for it, and silently saying names wrong +// is the failure this exists to remove. +func LoadLexicon(path string) (*Lexicon, error) { + if strings.TrimSpace(path) == "" { + return &Lexicon{}, nil + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("tts: lexicon %s: %w", path, err) + } + var entries map[string]string + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, fmt.Errorf("tts: lexicon %s: %w", path, err) + } + return NewLexicon(entries), nil +} + +// NewLexicon builds a lexicon from entries already in memory. +func NewLexicon(entries map[string]string) *Lexicon { + names := make([]string, 0, len(entries)) + by := make(map[string]string, len(entries)) + for name, say := range entries { + name = strings.TrimSpace(name) + if name == "" || strings.TrimSpace(say) == "" { + continue + } + names = append(names, name) + by[strings.ToLower(name)] = say + } + if len(names) == 0 { + return &Lexicon{} + } + // Longest first: "Home Assistant" must match before "Home" does, and Go's + // regexp alternation is leftmost-first, not longest-match. + sort.Slice(names, func(i, j int) bool { return len(names[i]) > len(names[j]) }) + quoted := make([]string, len(names)) + for i, n := range names { + quoted[i] = regexp.QuoteMeta(n) + } + // The boundaries are written out rather than left to \b, which is ASCII-only + // and never fires next to a Cyrillic letter — so "в Vikunja," would not + // match with \b on the left in a Russian sentence. + pattern := `(?i)(^|[^\p{L}\p{N}_])(` + strings.Join(quoted, "|") + `)($|[^\p{L}\p{N}_])` + return &Lexicon{re: regexp.MustCompile(pattern), by: by} +} + +// Apply rewrites every name in the text. Text with no name in it comes back +// unchanged and untouched. +func (l *Lexicon) Apply(text string) string { + if l == nil || l.re == nil || text == "" { + return text + } + // Twice, because two names separated by a single space share the character + // between them and one pass consumes it: "Nexus Praxis" would leave the + // second name alone otherwise. + out := l.replaceOnce(text) + return l.replaceOnce(out) +} + +func (l *Lexicon) replaceOnce(text string) string { + return l.re.ReplaceAllStringFunc(text, func(m string) string { + groups := l.re.FindStringSubmatch(m) + if groups == nil { + return m + } + say, ok := l.by[strings.ToLower(groups[2])] + if !ok { + return m + } + return groups[1] + say + groups[3] + }) +} + +// Size reports how many names are loaded, for the startup log line. +func (l *Lexicon) Size() int { + if l == nil { + return 0 + } + return len(l.by) +} diff --git a/internal/tts/lexicon_test.go b/internal/tts/lexicon_test.go new file mode 100644 index 0000000..8dbd175 --- /dev/null +++ b/internal/tts/lexicon_test.go @@ -0,0 +1,85 @@ +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) + } +}