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>
129 lines
4.4 KiB
Go
129 lines
4.4 KiB
Go
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)
|
|
}
|