// 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 // today; a faster-whisper / vosk-backed process when models land). 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 spec (maven.md § stt/tts): faster-whisper small/int8 is the production // stt; vosk-ru runs on the client (wake-word + stage-0 grammar), not here. // The server-side stt module is the heavy multilingual path; vosk's // stage-0 grammar hits the router directly via the client's stage-0 surface // and never crosses this seam — that path is post-MVP (the client doesn't // exist yet). 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 }