Files
Maven/cmd/mavttsd/piper_handler.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

152 lines
3.8 KiB
Go

package main
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"os/exec"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
type piperHandler struct {
piperPath string
modelPath string
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, lexicon *tts.Lexicon) *piperHandler {
return &piperHandler{
piperPath: piperPath,
modelPath: modelPath,
configPath: modelPath + ".json",
espeakData: espeakData,
tashkeelModel: tashkeelModel,
lexicon: lexicon,
}
}
func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq) (worker.SynthesizeResp, error) {
var stderr bytes.Buffer
args := []string{
"--model", h.modelPath,
"--config", h.configPath,
"--output_raw",
"--quiet",
}
if h.espeakData != "" {
args = append(args, "--espeak_data", h.espeakData)
}
if h.tashkeelModel != "" {
args = append(args, "--tashkeel_model", h.tashkeelModel)
}
cmd := exec.CommandContext(ctx, h.piperPath, args...)
cmd.Stderr = &stderr
stdin, err := cmd.StdinPipe()
if err != nil {
return worker.SynthesizeResp{}, fmt.Errorf("piper: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return worker.SynthesizeResp{}, fmt.Errorf("piper: stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
stdin.Close()
stdout.Close()
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
}
// 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()
return worker.SynthesizeResp{}, fmt.Errorf("piper: write text: %w", err)
}
stdin.Close()
rawPCM, readErr := io.ReadAll(stdout)
stdout.Close()
waitErr := cmd.Wait()
if waitErr != nil {
errMsg := stderr.String()
if errMsg != "" {
return worker.SynthesizeResp{}, fmt.Errorf("piper: %s: %s", waitErr, errMsg)
}
return worker.SynthesizeResp{}, fmt.Errorf("piper: %w", waitErr)
}
if readErr != nil {
return worker.SynthesizeResp{}, fmt.Errorf("piper: read stdout: %w", readErr)
}
if len(rawPCM) == 0 {
return worker.SynthesizeResp{}, fmt.Errorf("piper: no audio output")
}
resampled := resample22050To16000(rawPCM)
return worker.SynthesizeResp{
Audio: audio.Audio{
Format: audio.PCM16kMono,
Bytes: resampled,
},
}, nil
}
// resample22050To16000 converts raw 16-bit PCM from 22050 Hz to 16000 Hz
// using linear interpolation.
func resample22050To16000(input []byte) []byte {
if len(input) < 2 {
return nil
}
nSamples := len(input) / 2
outSamples := int(float64(nSamples) * 16000.0 / 22050.0)
output := make([]byte, outSamples*2)
ratio := 22050.0 / 16000.0
for i := 0; i < outSamples; i++ {
srcPos := float64(i) * ratio
srcIdx := int(srcPos)
frac := srcPos - float64(srcIdx)
if srcIdx >= nSamples-1 {
v := int16(binary.LittleEndian.Uint16(input[(nSamples-1)*2:]))
binary.LittleEndian.PutUint16(output[i*2:], uint16(v))
continue
}
v0 := int16(binary.LittleEndian.Uint16(input[srcIdx*2:]))
v1 := int16(binary.LittleEndian.Uint16(input[(srcIdx+1)*2:]))
interpolated := int16(float64(v0)*(1-frac) + float64(v1)*frac)
binary.LittleEndian.PutUint16(output[i*2:], uint16(interpolated))
}
return output
}