Files
claude 93987f2dfc docs: tier the tree by lifetime, so staleness shows in the path (V-446)
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>
2026-08-02 03:28:49 +04:00

112 lines
5.2 KiB
Go

// Package stt is maven's speech-to-text seam.
//
// Core calls exactly one method: `Transcribe(ctx, audio.Audio) (text, error)`.
// The interface is the swap seam:
//
// - Stub: deterministic, no model. Generates a fixed phrase per utterance
// so the daemon's reactive loop is exercisable end-to-end without any
// weights on disk (the "no models on disk" floor). The Stub hashes the
// audio bytes for a tiny bit of variation per utterance; the *content*
// of the audio doesn't matter, only the wire shape round-trips.
//
// - Remote: dials a worker module process at a unix socket (cmd/mavsttd,
// whisper.cpp-backed). The swap is one constructor change at the daemon
// seam; the boundary is the same.
//
// The Daemon picks the implementation from config. With no models on disk,
// it wires Stub (the audio path is "live" end to end, the transcribe step
// returns a canned string the router + action path operate on); with a
// worker socket configured, it wires Remote.
//
// Per docs/design.md § Voice pipeline (STT / TTS): whisper.cpp (CGo, Vulkan) in
// cmd/mavsttd is the production stt — the older faster-whisper/vosk picks are
// retired (docs/design.md § Superseded, "named STT/TTS model picks"). The
// server-side stt module is the heavy multilingual path; the client's
// wake-word + stage-0 command grammar (cmd/mavwaked) hits the router directly
// and never crosses this seam. Today's Remote + Stub both return plain text
// the router classifies.
package stt
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/worker"
)
// Transcriber — the speech-to-text contract. One method; one input shape;
// the output is plain text the router classifies. The implementation is
// impure (calls a model or a worker process); the Stub is pure (hash +
// template) and exists so the daemon runs end-to-end without a model.
type Transcriber interface {
Transcribe(ctx context.Context, a audio.Audio) (text string, confidence float64, err error)
}
// Stub — the deterministic, no-model floor. Returns a canned phrase derived
// from a hash of the audio bytes so two utterances differ on the wire but
// both stay stable across runs (a test fixture is reproducible). The
// returned text is shaped like a real utterance ("maven, отметь что я выпил
// воды" or "maven, remind me in 4 hours to stretch") so the router's
// cascade has something realistic to chew on during end-to-end exercises.
type Stub struct{}
// NewStub builds the floor transcriber. No config — the Stub is stateless.
func NewStub() *Stub { return &Stub{} }
// stubPhrases — the canned outputs the Stub rotates through. Each is a
// plausible utterance shape the router's stage-0 grammar or stage-1
// classifier will route to a different intent (act / reminder / fact / note
// / query). The hash picks the phrase per utterance deterministically.
var stubPhrases = []string{
"maven, отметь что я выпил воды", // fact (water tap → fact table)
"maven, напомни через 4 часа размяться", // reminder (→ reminders table)
"maven, restart nginx", // act (→ stage-0 grammar hit)
"maven, что у меня сегодня по календарю", // query (→ slm read-path, deferred)
"note: idea — staggered cooldown by time of day", // note (→ chroma, deferred)
"slept 6h, fan noise wrecked it", // compound capture (open spec; routes as fact today)
}
// Transcribe returns one of stubPhrases, indexed by a hash of the audio
// bytes. Empty audio ⇒ the first phrase (so a misconfigured client still
// gets a round-trip). Confidence is always 1.0 — the Stub is "certain" by
// construction, the router's confidence gate exercises on the classifier
// stage, not here.
func (s *Stub) Transcribe(_ context.Context, a audio.Audio) (string, float64, error) {
if len(a.Bytes) == 0 {
return stubPhrases[0], 1.0, nil
}
h := sha256.Sum256(a.Bytes)
idx := binary.BigEndian.Uint32(h[:4]) % uint32(len(stubPhrases))
return stubPhrases[idx], 1.0, nil
}
// Remote — the worker-backed Transcriber. Holds a worker.Client that dials
// the stt module's unix socket. The daemon constructs one when its config
// points at a worker socket; otherwise it uses the Stub.
type Remote struct {
c *worker.Client
lang string
}
// NewRemote builds a Remote Transcriber. lang is the default language hint
// passed to the worker for every call ("ru"/"en"/"mixed"); the worker may
// override per-call but the daemon doesn't today.
func NewRemote(c *worker.Client, lang string) *Remote {
return &Remote{c: c, lang: lang}
}
// Transcribe forwards to the worker module. A worker-side error (unknown
// method, bad params, internal panic-recovered) is returned wrapped so the
// daemon can log + continue — a transient stt fault doesn't kill the
// reactive path; the user gets a "sorry, didn't catch that" reply.
func (r *Remote) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) {
resp, err := r.c.Transcribe(ctx, worker.TranscribeReq{Audio: a, Lang: r.lang})
if err != nil {
return "", 0, fmt.Errorf("stt: transcribe: %w", err)
}
return resp.Text, resp.Confidence, nil
}