Files
Maven/internal/tts/tts.go
T
kami e0d0244fa9 Fold SPEC/maven/ROADMAP into DESIGN.md and drop the stale session logs
15 root markdown files, ~4,900 lines against ~33,000 lines of Go, with at least
three pairs contradicting each other. When five documents describe the
architecture, the code becomes the only trustworthy one — which defeats the
point of having them. That drift is why the resident-model question had four
incompatible answers.

SPEC.md, maven.md and ROADMAP.md are deduped into DESIGN.md rather than
concatenated, with a "Superseded" section carrying eight retired decisions and
what replaced each: classifier-owns-the-route (the cascade is still the live
path, but as a stopgap, not a design to extend), faster-whisper/vosk/silero,
the small-model phrasing claim, sqlcipher, the Kotlin/Spring sketches,
obsidian->chroma, script deployment, and FloorEnrollment. Superseded material
is kept and marked rather than deleted, so it cannot read as current.

SESSION-05/06-07-2026.md and PLANS.md are removed outright — git history holds
them, and both were verified tracked before deletion.

Go doc comments citing the deleted files are repointed to the equivalent
DESIGN.md sections. Several asserted designs that were already retired, so the
claims are corrected and not just relinked: stt.go named faster-whisper as
production (it is whisper.cpp), tts.go named silero (it is piper), intent.go
still described the classifier as owning the route, and stale vosk/chroma
vocabulary is replaced. ECOSYSTEM-SPEC.md references are deliberately
untouched — that is a different document, and a naive grep for SPEC.md matches
it.

Root markdown drops from 4,880 to ~3,700 lines. The review's ~1,500 target is
not reachable while keeping the files it also said to keep — those alone are
2,553 lines — so trimming further needs a separate decision on
MAVEN_ECOSYSTEM_ARCHITECTURE.md and PROGRESS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
2026-07-30 23:39:56 +04:00

94 lines
3.6 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 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 (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
}