a pronunciation dictionary, so piper stops reading hostnames as noise (V-458)

The RU voice reads a latin word letter by letter or guesses, so 'netdata'
came out as noise and 'homesrv' as nothing. pronounce_ru_v1.json spells the
sound in Cyrillic for the service names, hostnames and acronyms she actually
says, and Speakable applies it last, after the numbers around it are words.

Data, not code: nothing knows any of these names, and adding one is an edit
to the JSON. A word the table does not hold is left exactly as it was, so a
miss is the current behaviour rather than a guess. A malformed file logs and
loads empty, because speech must not stop over a dictionary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 01:56:11 +04:00
parent c82dbd1e65
commit b954e0cea6
4 changed files with 179 additions and 1 deletions
+68
View File
@@ -0,0 +1,68 @@
// ttsnorm/pronounce.go — how piper says the latin words that turn up inside a
// Russian sentence: service names, hostnames, acronyms (Vikunja #458).
//
// The RU voice reads latin letters one at a time or guesses, so "netdata" comes
// out as noise and "homesrv" comes out as nothing. The fix is spelling the
// sound in Cyrillic, and the mapping is data — nothing in the code knows any of
// these names, and adding one is an edit to pronounce_ru_v1.json.
//
// This is a rewrite over a latin token, not over Russian morphology: the
// pattern matches [a-z0-9] runs and asks the table. A word the table does not
// hold is left exactly as it was, so a miss is the current behaviour rather
// than a guess.
package ttsnorm
import (
_ "embed"
"encoding/json"
"log"
"regexp"
"strings"
)
//go:embed pronounce_ru_v1.json
var pronounceJSON []byte
// pronounceSchemaVersion — the version this loader understands.
const pronounceSchemaVersion = 1
// latinToken — one run of latin letters and digits. Cyrillic is untouched, so a
// Russian word next to an English one is never rewritten by accident.
var latinToken = regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*`)
// pronounce — lowercase word to its Russian spelling. Empty when the file
// failed to load, which leaves speech exactly as it was before this existed.
var pronounce = loadPronounce()
func loadPronounce() map[string]string {
var f struct {
SchemaVersion int `json:"schema_version"`
Entries map[string]string `json:"entries"`
}
if err := json.Unmarshal(pronounceJSON, &f); err != nil {
// Speech must not stop because a dictionary is malformed.
log.Printf("ttsnorm: pronunciation dictionary: %v", err)
return map[string]string{}
}
if f.SchemaVersion != pronounceSchemaVersion {
log.Printf("ttsnorm: pronunciation dictionary schema_version %d, want %d — not loading it",
f.SchemaVersion, pronounceSchemaVersion)
return map[string]string{}
}
return f.Entries
}
// Pronounce rewrites every latin word the dictionary knows. Case-insensitive on
// the way in ("Netdata", "NETDATA" and "netdata" are one word) and lowercase on
// the way out, because the value is a sound and not a name.
func Pronounce(s string) string {
if len(pronounce) == 0 {
return s
}
return latinToken.ReplaceAllStringFunc(s, func(w string) string {
if say, ok := pronounce[strings.ToLower(w)]; ok {
return say
}
return w
})
}
+47
View File
@@ -0,0 +1,47 @@
{
"schema_version": 1,
"_comment": "How piper should say latin words inside a Russian sentence. Keys are lowercase and matched whole, so 'nexus' is rewritten and 'nexuses' is not. Values are Russian spelling of the sound, which is the only thing piper's RU voice can read. Adding a word here is a data change: nothing in the code knows any of these names.",
"entries": {
"maven": "мэйвен",
"nexus": "нексус",
"praxis": "праксис",
"hexis": "хексис",
"vikunja": "викунья",
"homesrv": "хоумсерв",
"workpc": "воркписи",
"kuma": "кума",
"netdata": "нетдата",
"paperless": "пейперлес",
"gitea": "гитея",
"docker": "докер",
"kiwix": "кивикс",
"searxng": "серч эн джи",
"piper": "пайпер",
"whisper": "виспер",
"caldav": "калдав",
"telegram": "телеграм",
"ntfy": "нотифай",
"imap": "аймап",
"smtp": "эс эм ти пи",
"http": "эйч ти ти пи",
"https": "эйч ти ти пи эс",
"api": "эй пи ай",
"url": "юарэль",
"cpu": "цэпэу",
"gpu": "джипиу",
"ram": "рам",
"ssd": "эсэсди",
"hdd": "эйчдиди",
"usb": "юэсби",
"vpn": "вэпээн",
"nas": "нас",
"dns": "дээнэс",
"wifi": "вайфай",
"pdf": "пэдээф",
"json": "джейсон",
"llm": "элэлэм",
"tts": "титиэс",
"stt": "эстиэти",
"ok": "окей"
}
}
+61
View File
@@ -0,0 +1,61 @@
package ttsnorm
import (
"strings"
"testing"
)
func TestDictionaryLoads(t *testing.T) {
// An empty map is the failure mode, and it is silent at runtime by design.
if len(pronounce) == 0 {
t.Fatal("pronunciation dictionary is empty — it failed to load or failed its version check")
}
}
func TestPronounceRewritesAKnownService(t *testing.T) {
got := Pronounce("netdata говорит что диск заполнен")
if strings.Contains(got, "netdata") {
t.Fatalf("got %q, want the latin name spelled in Cyrillic", got)
}
}
func TestPronounceIsCaseInsensitive(t *testing.T) {
for _, in := range []string{"Netdata", "NETDATA", "netdata"} {
if got := Pronounce(in); got != pronounce["netdata"] {
t.Errorf("Pronounce(%q) = %q, want %q", in, got, pronounce["netdata"])
}
}
}
func TestPronounceLeavesUnknownWordsAlone(t *testing.T) {
// A miss must be the old behaviour, never a guess.
const in = "zzqx упал"
if got := Pronounce(in); got != in {
t.Fatalf("Pronounce(%q) = %q, want it untouched", in, got)
}
}
func TestPronounceDoesNotTouchRussian(t *testing.T) {
const in = "напомню завтра в 19:00"
if got := Pronounce(in); got != in {
t.Fatalf("Pronounce(%q) = %q, want Cyrillic untouched", in, got)
}
}
func TestPronounceMatchesWholeWordsOnly(t *testing.T) {
// "nexuses" is not "nexus", and half-rewriting a word is worse than not
// rewriting it.
if got := Pronounce("nexuses"); got != "nexuses" {
t.Fatalf("Pronounce(\"nexuses\") = %q, want it untouched", got)
}
}
func TestSpeakableAppliesTheDictionary(t *testing.T) {
got := Speakable("homesrv: 10.07.2026")
if strings.Contains(got, "homesrv") {
t.Fatalf("Speakable did not apply the dictionary: %q", got)
}
if !strings.Contains(got, "июля") {
t.Fatalf("Speakable stopped rewriting dates: %q", got)
}
}
+3 -1
View File
@@ -41,7 +41,9 @@ func Speakable(s string) string {
p := reDate.FindStringSubmatch(m)
return spokenDate(p[1], p[2], "")
})
return s
// Last, so a hostname is spelled out after the numbers around it are
// already words and no rewrite above can see Cyrillic it did not expect.
return Pronounce(s)
}
func spokenDate(dd, mm, yyyy string) string {