// 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 }