93987f2dfc
Seventeen markdown files at the repo root, twelve of them dated one-shot reports sitting next to CLAUDE.md. That is why stale docs read as current: nothing in the path said which was which. Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/ and carry a Last verified line. Dated measurements move to docs/evals/ ISO-prefixed, and are never edited after the day, so a newer number is a new file. The senior review moves to docs/archive/. Every reference was rewritten across markdown, Go comments, the Makefile and the recall fixture. The touched Go packages still build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
3.7 KiB
Go
94 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 docs/design.md § Voice pipeline (STT / TTS): piper is the production tts
|
|
// (subprocess + espeak-ng, CPU-only on the ryzen box, driven by cmd/mavttsd);
|
|
// the older silero pick is retired (docs/design.md § Superseded, "named STT/TTS
|
|
// model picks"). A different voice is a model-file swap, not a code change.
|
|
// The daemon wires one impl — 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
|
|
}
|