Files
Maven/cmd/mavttsd/main.go
T
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

143 lines
4.4 KiB
Go

// Package main is mavttsd — maven's tts module process.
//
// Sibling to cmd/mavsttd: same worker boundary, opposite job (synthesize vs
// transcribe). Same restart-free, key-free, fail-independent invariant.
//
// With -piper <binary> -model <onnx>: calls piper for real TTS (ru_RU
// voice at models/tts/ru_RU-irina-medium.onnx). Without flags: serves the
// stub synthesizer (200ms tone) for exercisable end-to-end testing.
//
// $ mavttsd -socket /run/user/$UID/maven/tts.sock
// "tts": { "socket": "/run/user/1000/maven/tts.sock", "lang": "ru" }
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"syscall"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "mavttsd:", err)
os.Exit(1)
}
}
func run(args []string) error {
sock := flag.String("socket", defaultSocket("tts.sock"), "unix socket path")
piperBin := flag.String("piper", "", "path to piper binary")
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, lex)
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
} else {
log.Printf("mavttsd: no piper/model specified, using stub handler")
s = &stubHandler{}
}
srv := worker.NewSynthesizerServer(*sock, s)
if err := srv.Listen(); err != nil {
return err
}
defer srv.Close()
log.Printf("mavttsd: worker listening on %s", srv.Path())
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve() }()
select {
case <-ctx.Done():
log.Printf("mavttsd: shutdown signal received")
srv.Close()
return nil
case err := <-errCh:
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
}
return nil
}
}
// stubHandler — worker.Synthesizer that delegates to the tts Stub. The
// production swap replaces this struct with a silero / piper-backed handler.
type stubHandler struct{}
func (h *stubHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq) (worker.SynthesizeResp, error) {
_ = ctx
// 200ms tone, freq keyed by first byte of text — same shape as tts.Stub,
// kept locally so this module has zero coupling to the daemon package
// (mavttsd running shouldn't drag stt/tts package symbols here; they're
// siblings in the topology).
const samples = 3200 // 200ms @ 16k
pcm := make([]byte, samples*2)
freq := 220.0
if len(req.Text) > 0 {
freq = 180.0 + float64(req.Text[0]%6)*60
}
for i := 0; i < samples; i++ {
t := float64(i) / 16000.0
v := int16(12000 * sin(2*pi*freq*t))
pcm[i*2] = byte(v)
pcm[i*2+1] = byte(v >> 8)
}
return worker.SynthesizeResp{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}}, nil
}
const pi = 3.141592653589793
// tiny stdlib-free sin approximation — keeps mavttsd out of math import.
// Adequate for a tone generator; the production model returns real audio.
func sin(x float64) float64 {
// reduce to [-pi, +pi]
mod := x - pi*2*float64(int(x/(pi*2)))
if mod > pi {
mod -= pi * 2
} else if mod < -pi {
mod += pi * 2
}
// 4-term Taylor series around 0; decent for the small amplitudes here.
return mod - mod*mod*mod/6 + mod*mod*mod*mod*mod/120 - mod*mod*mod*mod*mod*mod*mod/5040
}
func defaultSocket(name string) string {
if x := os.Getenv("XDG_RUNTIME_DIR"); x != "" {
return x + "/maven/" + name
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return name
}
return home + "/.local/share/maven/" + name
}