Files
Maven/internal/tts/tts.go
T
2026-07-03 00:32:48 +02:00

93 lines
3.7 KiB
Go

// Package tts is maven's text-to-speech seam.
//
// Mirrors internal/stt: one method, two implementations (Stub + Remote), one
// swap seam at the daemon. The output is audio.Audio — raw 16k mono int16
// PCM, headerless per the audio package; the voice sink + reference client
// wrap it in a WAV at the disk edge.
//
// Per spec (maven.md § stt/tts): silero (ru-native) is the production tts,
// piper (ru) is the safe floor. Both are CPU-only on the ryzen box; both
// ship as separate worker module processes (cmd/mavttsd today with the
// Stub handler; production swaps in onnxruntime / espeak-ng in the same
// main, no tts-package change). The daemon wires one — Remote pointing at
// the worker socket if configured, Stub otherwise.
//
// The Stub returns a short deterministic tone (a 200ms mid-frequency sine
// burst) so the voice loop round-trips end-to-end without a model. The
// reference client can `aplay` the reply, hear a tone, and know the wire
// shape is right; the production swap replaces the bytes with model output.
package tts
import (
"context"
"fmt"
"math"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/worker"
)
// Synthesizer — the text-to-speech contract. Input is text the phraser has
// already rendered (Body for proactive nudges, reply text for reactive).
// Output is raw PCM audio the delivery / client surfaces ship.
type Synthesizer interface {
Synthesize(ctx context.Context, text string) (audio.Audio, error)
}
// Stub — the deterministic, no-model floor. Returns a fixed-duration tone
// keyed by the input text's first byte so different replies produce
// slightly different tones (a test asserting "the voice reply was sent"
// can distinguish them; a human smoke-testing hears that SOMETHING came
// back, not silence). 200ms at 16k mono int16 ⇒ 6400 bytes — small frames,
// instant over the wire.
type Stub struct{}
// NewStub builds the floor synthesizer.
func NewStub() *Stub { return &Stub{} }
// Synthesize returns a 200ms tone derived from the first byte of text.
// Empty text ⇒ a low tone (so an empty reply is still audible, not a
// silent no-op a bug could hide behind).
func (s *Stub) Synthesize(_ context.Context, text string) (audio.Audio, error) {
const durMs = 200
const samples = 16000 * durMs / 1000 // 3200 samples @ 16k
pcm := make([]byte, samples*2)
freq := 220.0 // A3
if len(text) > 0 {
freq = 180.0 + float64(text[0]%6)*60 // 180..480 Hz band
}
for i := 0; i < samples; i++ {
t := float64(i) / 16000.0
v := int16(12000 * math.Sin(2*math.Pi*freq*t))
pcm[i*2] = byte(v)
pcm[i*2+1] = byte(v >> 8)
}
return audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, nil
}
// Remote — the worker-backed Synthesizer. Holds a worker.Client that dials
// the tts module's unix socket.
type Remote struct {
c *worker.Client
lang string
voice string
}
// NewRemote builds a Remote Synthesizer. lang is the default voice language;
// voice is the named voice ("" ⇒ the worker's configured default).
func NewRemote(c *worker.Client, lang, voice string) *Remote {
return &Remote{c: c, lang: lang, voice: voice}
}
// Synthesize forwards to the worker module. A worker-side fault returns an
// empty Audio + error; the dispatcher's voice path logs and skips (a
// transient TTS fault drops the voice channel for that one send; away
// channels like ntfy/telegram still fire because their sinks are
// independent).
func (r *Remote) Synthesize(ctx context.Context, text string) (audio.Audio, error) {
resp, err := r.c.Synthesize(ctx, worker.SynthesizeReq{Text: text, Lang: r.lang, Voice: r.voice})
if err != nil {
return audio.Audio{}, fmt.Errorf("tts: synthesize: %w", err)
}
return resp.Audio, nil
}