67decc42f0
mavsttd/whisper_handler.go: name the no_speech_prob confidence-zeroing floor (0.9) and the whisper thread count (4), both previously bare literals with no reason attached. mavttsd/piper_handler.go: name piper's render rate (22050) and the canonical wire rate (16000) used by the resampler, instead of repeating the two numbers inline four times. mavenclient/main.go: remove the strconv/io/net/time imports and their `var _ = ...` keep-alive lines — dead weight with no caller, not future scaffolding. Behaviour-preserving; no test changed. go test -race ./internal/... ./cmd/... is green.
161 lines
4.2 KiB
Go
161 lines
4.2 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
|
|
}
|
|
|
|
// piperSampleRate is the rate piper's onnx voices render at (ru_RU-irina and
|
|
// the other models this daemon has been pointed at). targetSampleRate is the
|
|
// canonical maven wire rate (audio.PCM16kMono) that every downstream
|
|
// consumer — playback, the voice wire, whisper on the way back in — expects.
|
|
const (
|
|
piperSampleRate = 22050
|
|
targetSampleRate = 16000
|
|
)
|
|
|
|
// resample22050To16000 converts raw 16-bit PCM from piperSampleRate to
|
|
// targetSampleRate using linear interpolation.
|
|
func resample22050To16000(input []byte) []byte {
|
|
if len(input) < 2 {
|
|
return nil
|
|
}
|
|
|
|
nSamples := len(input) / 2
|
|
outSamples := int(float64(nSamples) * float64(targetSampleRate) / float64(piperSampleRate))
|
|
output := make([]byte, outSamples*2)
|
|
|
|
ratio := float64(piperSampleRate) / float64(targetSampleRate)
|
|
|
|
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
|
|
}
|