Pronunciation dictionary for piper #138
+14
-1
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Maven": "Мэйвен",
|
||||
"Nexus": "Нексус",
|
||||
"Praxis": "Праксис",
|
||||
"Hexis": "Хексис",
|
||||
"Vikunja": "Викунья",
|
||||
|
|
||||
"SearXNG": "сёрчиксэнджи",
|
||||
|
kami
commented
"сирИксЭнДжи" "сирИксЭнДжи"
|
||||
"Kiwix": "Кивикс",
|
||||
"Gitea": "Гитея",
|
||||
|
kami
commented
"Гитеа" "Гитеа"
|
||||
"Home Assistant": "Хоум Ассистент",
|
||||
"Docker": "Докер",
|
||||
"Telegram": "Телеграм",
|
||||
"ntfy": "энтифай",
|
||||
"homesrv": "хоумсёрв",
|
||||
|
kami
commented
"хоумэсэрвэ" "хоумэсэрвэ"
|
||||
"workpc": "воркписи",
|
||||
"whisper": "виспер",
|
||||
"piper": "пайпер",
|
||||
"llama-server": "лама сервер",
|
||||
"Qwen": "Квен",
|
||||
"CalDAV": "калдав",
|
||||
"IMAP": "аймап",
|
||||
"API": "эй-пи-ай",
|
||||
"CPU": "си-пи-ю",
|
||||
"GPU": "джи-пи-ю",
|
||||
"RAM": "оперативная память",
|
||||
"SSD": "эс-эс-ди",
|
||||
"uptime": "аптайм",
|
||||
"backup": "бэкап",
|
||||
"deploy": "деплой"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user
oh, and I was thinking "Викунджа".