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) }