Files
Maven/internal/tts/tts.go
T
claude 936c6d71db audio and tts sweep: walk WAV chunks, name the stub sample rate (V-581)
The WAV parser now walks chunk headers to find the data chunk instead of
scanning for the four bytes "data". A LIST chunk between fmt and data is
common, arecord and ffmpeg both write one, and its payload is free text that
can spell the word. A byte scan took that text for a chunk header and read the
comment as samples.

WAVHeader named WAVFromPCM in its error, so a caller of WAVHeader read the
wrong function. internal/capture calls it twice.

PCMFromWAV returns PCM that aliases the buffer it was given. That is the right
trade for a long recording and it was undocumented, so the doc comment now says
so and names the two ways a caller gets it wrong.

The TTS stub wrote 16000 three times. It reads the rate and the sample width
off audio.PCM16kMono now, so the tone stays in tune with the shape the seam
declares, and the sample write goes through binary.LittleEndian.

Identify computed the clip length twice to report it once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:28:45 +04:00

98 lines
3.9 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"
"encoding/binary"
"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
// The rate is read off the canonical format rather than written again, so
// the tone stays in tune with the shape the seam declares.
rate := audio.PCM16kMono.SampleRate
bytesPerSample := audio.PCM16kMono.SampleBits / 8
samples := rate * durMs / 1000
pcm := make([]byte, samples*bytesPerSample)
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) / float64(rate)
v := int16(12000 * math.Sin(2*math.Pi*freq*t))
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v))
}
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
}