Files
claude 5afa2dfb38 mavttsd: a pronunciation dictionary, so she says the names right (V-458)
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>
2026-08-04 04:11:10 +04:00

136 lines
3.7 KiB
Go

package main
import (
"bytes"
"context"
"path/filepath"
"testing"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
func TestStubHandlerSynthesize_ReturnsPCM16kMono(t *testing.T) {
h := &stubHandler{}
resp, err := h.Synthesize(context.Background(), worker.SynthesizeReq{Text: "hello"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Audio.Format != audio.PCM16kMono {
t.Errorf("format = %v, want %v", resp.Audio.Format, audio.PCM16kMono)
}
}
func TestStubHandlerSynthesize_NonEmptyAudio(t *testing.T) {
h := &stubHandler{}
resp, err := h.Synthesize(context.Background(), worker.SynthesizeReq{Text: "hello"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Audio.Bytes) == 0 {
t.Error("audio bytes are empty")
}
if len(resp.Audio.Bytes) != 6400 {
t.Logf("audio bytes = %d (expected ~6400 for 200ms@16kHz)", len(resp.Audio.Bytes))
}
}
func TestStubHandlerSynthesize_DifferentTextDifferentAudio(t *testing.T) {
h := &stubHandler{}
a, _ := h.Synthesize(context.Background(), worker.SynthesizeReq{Text: "hello"})
b, _ := h.Synthesize(context.Background(), worker.SynthesizeReq{Text: "world"})
if bytes.Equal(a.Audio.Bytes, b.Audio.Bytes) {
t.Error("different text produced identical audio")
}
}
func TestStubHandlerSynthesize_EmptyTextProducesAudio(t *testing.T) {
h := &stubHandler{}
resp, err := h.Synthesize(context.Background(), worker.SynthesizeReq{Text: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Audio.Bytes) == 0 {
t.Error("empty text produced no audio")
}
}
func TestResample22050To16000_EmptyInput(t *testing.T) {
out := resample22050To16000(nil)
if len(out) != 0 {
t.Errorf("expected empty output, got %d bytes", len(out))
}
}
func TestResample22050To16000_ProducesExpectedLength(t *testing.T) {
input := make([]byte, 44100)
for i := range input {
input[i] = byte(i)
}
out := resample22050To16000(input)
expected := len(input) * 16000 / 22050
if diff := abs(len(out) - expected); diff > 4 {
t.Errorf("output length = %d, want ~%d (diff %d)", len(out), expected, diff)
}
}
func TestDefaultSocket_WithXdgRuntimeDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000")
path := defaultSocket("tts.sock")
want := "/run/user/1000/maven/tts.sock"
if path != want {
t.Errorf("got %q, want %q", path, want)
}
}
func TestSynthesizeRoundTripViaSocket(t *testing.T) {
sockDir := t.TempDir()
sockPath := filepath.Join(sockDir, "tts.sock")
h := &stubHandler{}
srv := worker.NewSynthesizerServer(sockPath, h)
if err := srv.Listen(); err != nil {
t.Fatal(err)
}
go srv.Serve()
defer srv.Close()
client := worker.Dial(sockPath)
defer client.Close()
ctx := context.Background()
resp, err := client.Synthesize(ctx, worker.SynthesizeReq{Text: "test"})
if err != nil {
t.Fatalf("synthesize: %v", err)
}
if resp.Audio.Format != audio.PCM16kMono {
t.Errorf("format = %v, want %v", resp.Audio.Format, audio.PCM16kMono)
}
if len(resp.Audio.Bytes) == 0 {
t.Error("audio bytes are empty")
}
}
func abs(n int) int {
if n < 0 {
return -n
}
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")
}
}