diff --git a/cmd/mavend/sttseam_test.go b/cmd/mavend/sttseam_test.go new file mode 100644 index 0000000..007e3cb --- /dev/null +++ b/cmd/mavend/sttseam_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + + "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/stt" +) + +// A box with no workstation.stt block transcribes exactly as it did before the +// seam existed: the floor is handed back untouched, and nothing probes. +func TestSttSeamWithNoBlockIsTheFloor(t *testing.T) { + floor := stt.NewStub() + got, pair := sttSeam(&config.Config{}, floor) + if pair != nil { + t.Fatal("no block must build no pair") + } + if got != stt.Transcriber(floor) { + t.Fatal("no block must hand back the floor itself") + } +} + +func TestSttSeamPrefersTheWorkstation(t *testing.T) { + cfg := &config.Config{Workstation: &config.WorkstationConfig{ + URL: "http://127.0.0.1:1", + Stt: &config.WorkstationSttConfig{ + URL: "http://127.0.0.1:2/transcribe", + Health: "http://127.0.0.1:2/health", + }, + }} + got, pair := sttSeam(cfg, stt.NewStub()) + if pair == nil { + t.Fatal("a configured block must build a pair") + } + defer pair.Stop() + if got != stt.Transcriber(pair) { + t.Fatal("the pair is what callers must transcribe through") + } + // Nothing answers on port 2, so the seam is the floor until it does. + if pair.Available() { + t.Fatal("an unreachable workstation must not be available") + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 9e308e9..65acddf 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -53,7 +53,11 @@ type voiceWiring struct { // unless a `workstation` block names an address. Held here only so the // prober is stopped on shutdown; callers were handed it at build time. pair *llm.Pair - mcp *mcpWiring + // sttPair — CrisperWhisper 2.0 on the workstation with mavsttd as the + // floor, nil unless the `workstation.stt` block names an address. Held for + // the same reason as pair: to stop its prober on shutdown. + sttPair *stt.Pair + mcp *mcpWiring // home — the Home Assistant client, nil unless the `smarthome` block is // enabled (Vikunja #256). Its devices land in the same allowlist as every // other act, so nothing else here has to know about it. @@ -84,6 +88,9 @@ func (w *voiceWiring) close() { if w.pair != nil { w.pair.Stop() } + if w.sttPair != nil { + w.sttPair.Stop() + } w.mcp.close() } @@ -112,6 +119,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem } else { transcriber = stt.NewStub() } + transcriber, w.sttPair = sttSeam(cfg, transcriber) w.transcriber = transcriber // ----- tts (Stub in-process OR Remote) ----- @@ -366,6 +374,44 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm return pair, pair } +// sttSeam builds the transcription seam the voice path and the meeting +// recorder share. It is modelSeam for audio and follows the same rule. +// +// With no `workstation.stt` block it hands back the floor untouched, which is +// today's deploy exactly. With one, it is an stt.Pair preferring CrisperWhisper +// 2.0 on workpc, which scores 10.4% WER in Russian against the floor's 27.5% +// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md). +// +// Only the silent half of the degradation rule applies here. A worse transcript +// is still a turn, so there is nothing to name a gap about and the fallback is +// never spoken. That is why stt.Pair has no TranscribeRemote. +func sttSeam(cfg *config.Config, floor stt.Transcriber) (stt.Transcriber, *stt.Pair) { + if cfg.Workstation == nil || cfg.Workstation.Stt == nil { + return floor, nil + } + s := cfg.Workstation.Stt + lang := "" + if cfg.Voice != nil { + lang = cfg.Voice.Lang + if cfg.Voice.Stt != nil && cfg.Voice.Stt.Lang != "" { + lang = cfg.Voice.Stt.Lang + } + } + pair := stt.NewPair( + stt.NewHTTPTranscriber(s.URL, s.Token, lang, time.Duration(s.Timeout)), + floor, + s.Health, + time.Duration(s.Probe), + ) + pair.Start(context.Background()) + if s.Token == "" { + log.Print("voice: the workstation transcriber has no token, so anything on the LAN can post audio to it") + } + log.Printf("voice: workstation transcriber at %s, probed every %s, mavsttd as the floor", + s.URL, time.Duration(s.Probe)) + return pair, pair +} + func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter { if !enabled { return nil diff --git a/internal/config/workstation.go b/internal/config/workstation.go index 7a79813..6fab688 100644 --- a/internal/config/workstation.go +++ b/internal/config/workstation.go @@ -1,6 +1,7 @@ package config import ( + "net/url" "strings" "time" ) @@ -35,12 +36,54 @@ type WorkstationConfig struct { // 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than // the resident one, and a request that overruns falls back to the floor. Timeout Duration `json:"timeout,omitempty"` + + // Stt — CrisperWhisper 2.0 on the same machine, a separate service on its + // own port. Absent ⇒ every utterance goes to mavsttd, which is today. + Stt *WorkstationSttConfig `json:"stt,omitempty"` +} + +// WorkstationSttConfig — speech-to-text on the workstation. +// +// It is a second service and not a second endpoint on mavgpud: whisper.cpp +// cannot load CrisperWhisper 2.0 at all, because it derives its language count +// from the vocabulary size and CW2's 51897 tokens shift seven special token +// ids. So CW2 runs under transformers, and this block addresses it. +// +// Worth the trouble: CW2 turbo scores 10.4% WER in Russian against 27.5% for +// the ggml-small.bin homesrv loads +// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md). +type WorkstationSttConfig struct { + // URL — the transcribe endpoint, e.g. + // "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised + // to nil and mavsttd takes every turn. + URL string `json:"url,omitempty"` + + // Health — the admission endpoint. Empty ⇒ the URL's origin + "/health". + // It answers 503 while the card is held, and that is the signal. + Health string `json:"health,omitempty"` + + // Token — the bearer token the service checks. Audio is the most sensitive + // thing that crosses this seam, so a LAN deployment should set one. Write + // it as ${MAVEN_STT_TOKEN} and keep the value in deploy/telegram.env, the + // way every other secret in this file is written. + Token string `json:"token,omitempty"` + + // Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe. + Probe Duration `json:"probe,omitempty"` + + // Timeout — the per-request budget for one utterance. 0 ⇒ + // DefaultWorkstationSttTimeout. A request that overruns falls back to + // mavsttd, which costs a worse transcript and not the turn. + Timeout Duration `json:"timeout,omitempty"` } // Workstation defaults, applied in normaliseWorkstation. const ( DefaultWorkstationProbe = 15 * time.Second DefaultWorkstationTimeout = 90 * time.Second + // One utterance, not one completion. A voice turn waits on this, so the + // budget is a few seconds and not a minute and a half. + DefaultWorkstationSttTimeout = 10 * time.Second ) // normaliseWorkstation applies the block's defaults. No address, no preferred @@ -63,4 +106,36 @@ func (c *Config) normaliseWorkstation() { if w.Timeout <= 0 { w.Timeout = Duration(DefaultWorkstationTimeout) } + normaliseWorkstationStt(w) +} + +// normaliseWorkstationStt applies the speech-to-text block's defaults. No +// address, no remote: mavsttd then takes every utterance, which is today. +func normaliseWorkstationStt(w *WorkstationConfig) { + if w.Stt != nil && strings.TrimSpace(w.Stt.URL) == "" { + w.Stt = nil + } + if w.Stt == nil { + return + } + s := w.Stt + if strings.TrimSpace(s.Health) == "" { + s.Health = healthOrigin(s.URL) + } + if s.Probe <= 0 { + s.Probe = Duration(DefaultWorkstationProbe) + } + if s.Timeout <= 0 { + s.Timeout = Duration(DefaultWorkstationSttTimeout) + } +} + +// healthOrigin derives the admission endpoint from the transcribe endpoint. +// The URL names a path, so appending to it would ask for /transcribe/health. +func healthOrigin(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return strings.TrimRight(raw, "/") + "/health" + } + return u.Scheme + "://" + u.Host + "/health" }