Files
Maven/internal/stt/stt.go
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

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