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 }