Wire the transcription seam beside the model seam (V-486)

sttSeam is modelSeam for audio and sits at the same place in wireVoice, so
the voice path and the meeting recorder share one transcriber as they
always have.

A box with no workstation.stt block behaves byte-for-byte as it did before
this existed: the floor is handed back untouched and nothing probes. An
empty URL is normalised to no block at all, the way the model block already
works.

Health defaults to the URL's origin rather than the URL itself, because the
transcribe endpoint names a path and appending would ask for
/transcribe/health. A block with no token logs once that anything on the
LAN can post audio to that port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
This commit is contained in:
2026-08-09 00:42:15 +04:00
parent a1e97c94ac
commit cc32c2c4ab
3 changed files with 165 additions and 1 deletions
+43
View File
@@ -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")
}
}
+47 -1
View File
@@ -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