From c7f59e48f40ac521479bd553d2c565a3ef421fc5 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 00:41:55 +0400 Subject: [PATCH 1/3] CrisperWhisper reads audio over HTTP, not a socket (V-486) mavsttd is whisper.cpp linked into a Go daemon and reached over a unix socket. CrisperWhisper 2.0 cannot be reached that way. whisper.cpp derives its language count from the vocabulary size, and CW2's 51897 tokens shift seven special token ids, so it never loads at all. So it runs under transformers on workpc and this is the client. Same stt.Transcriber interface and one method, a second transport rather than a second seam. The body is the PCM itself, because a minute of 16kHz mono is under 2MB raw and the format is fixed by audio.PCM16kMono. Audio is the most sensitive thing that crosses this seam, so the client carries a bearer token. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- internal/stt/http.go | 92 +++++++++++++++++++++++++++++++++++++++ internal/stt/http_test.go | 89 +++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 internal/stt/http.go create mode 100644 internal/stt/http_test.go diff --git a/internal/stt/http.go b/internal/stt/http.go new file mode 100644 index 0000000..adf04fd --- /dev/null +++ b/internal/stt/http.go @@ -0,0 +1,92 @@ +package stt + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/kami/maven/internal/audio" +) + +// HTTPTranscriber — speech-to-text on another host, over HTTP. +// +// mavsttd is whisper.cpp linked into a Go daemon and reached over a unix +// socket. CrisperWhisper 2.0 cannot be reached that way: whisper.cpp derives +// its language count from the vocabulary size, and CW2's 51897 tokens shift +// seven special token ids. It runs under transformers instead, as a service +// beside the model on workpc. See docs/evals/2026-08-09-crisperwhisper2-russian-wer.md. +// +// So this is the second transport for the same seam, not a second seam. The +// caller still sees stt.Transcriber and one method. +type HTTPTranscriber struct { + url string + token string + lang string + http *http.Client +} + +// NewHTTPTranscriber builds the remote client. token may be empty for a +// service on a trusted socket, but audio is the most sensitive thing that +// crosses this seam, so a LAN deployment should always set one. +func NewHTTPTranscriber(url, token, lang string, timeout time.Duration) *HTTPTranscriber { + return &HTTPTranscriber{ + url: url, + token: token, + lang: lang, + http: &http.Client{Timeout: timeout}, + } +} + +// ErrFormat — the audio is not the one canonical shape. Refused at the seam +// rather than sent to a model that expects something else. +var ErrFormat = errors.New("stt: audio is not 16kHz mono pcm_s16le") + +type httpTranscript struct { + Text string `json:"text"` + Confidence float64 `json:"confidence"` +} + +// Transcribe posts the raw PCM and reads back the text. +// +// The body is the PCM bytes themselves rather than JSON. A minute of 16kHz +// mono is under 2MB raw and about 2.6MB base64, and the format is fixed by +// audio.PCM16kMono, so a header carries it more cheaply than an envelope. +func (t *HTTPTranscriber) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) { + if !a.Format.IsValid() { + return "", 0, ErrFormat + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(a.Bytes)) + if err != nil { + return "", 0, fmt.Errorf("stt: build request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("X-Sample-Rate", strconv.Itoa(a.Format.SampleRate)) + req.Header.Set("X-Channels", strconv.Itoa(a.Format.Channels)) + req.Header.Set("X-Sample-Bits", strconv.Itoa(a.Format.SampleBits)) + req.Header.Set("X-Language", t.lang) + if t.token != "" { + req.Header.Set("Authorization", "Bearer "+t.token) + } + + resp, err := t.http.Do(req) + if err != nil { + return "", 0, fmt.Errorf("stt: post audio: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("stt: remote returned %d", resp.StatusCode) + } + + var out httpTranscript + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", 0, fmt.Errorf("stt: decode transcript: %w", err) + } + return out.Text, out.Confidence, nil +} + +var _ Transcriber = (*HTTPTranscriber)(nil) diff --git a/internal/stt/http_test.go b/internal/stt/http_test.go new file mode 100644 index 0000000..85d3289 --- /dev/null +++ b/internal/stt/http_test.go @@ -0,0 +1,89 @@ +package stt + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/kami/maven/internal/audio" +) + +func TestHTTPTranscriberSendsRawPCM(t *testing.T) { + t.Parallel() + var gotBody []byte + var gotHeader http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotHeader = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"text":"привет","confidence":0.82}`) + })) + defer srv.Close() + + a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("pcm-bytes")} + tr := NewHTTPTranscriber(srv.URL, "s3cret", "ru", 2*time.Second) + text, conf, err := tr.Transcribe(context.Background(), a) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if text != "привет" || conf != 0.82 { + t.Fatalf("got %q %v", text, conf) + } + if string(gotBody) != "pcm-bytes" { + t.Fatalf("body should be the PCM itself, got %q", gotBody) + } + if got := gotHeader.Get("X-Sample-Rate"); got != strconv.Itoa(audio.PCM16kMono.SampleRate) { + t.Fatalf("X-Sample-Rate = %q", got) + } + if got := gotHeader.Get("X-Language"); got != "ru" { + t.Fatalf("X-Language = %q", got) + } + // Audio is the most sensitive thing crossing this seam. + if got := gotHeader.Get("Authorization"); got != "Bearer s3cret" { + t.Fatalf("Authorization = %q", got) + } +} + +func TestHTTPTranscriberOmitsEmptyToken(t *testing.T) { + t.Parallel() + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + _, _ = io.WriteString(w, `{"text":"x"}`) + })) + defer srv.Close() + a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")} + if _, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second).Transcribe(context.Background(), a); err != nil { + t.Fatalf("Transcribe: %v", err) + } + if auth != "" { + t.Fatalf("Authorization should be absent, got %q", auth) + } +} + +func TestHTTPTranscriberRefusesWrongFormat(t *testing.T) { + t.Parallel() + a := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}} + _, _, err := NewHTTPTranscriber("http://example.invalid", "", "ru", time.Second).Transcribe(context.Background(), a) + if !errors.Is(err, ErrFormat) { + t.Fatalf("want ErrFormat, got %v", err) + } +} + +func TestHTTPTranscriberErrorsOnBadStatus(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")} + _, _, err := NewHTTPTranscriber(srv.URL, "", "ru", time.Second).Transcribe(context.Background(), a) + if err == nil { + t.Fatal("a 401 must be an error, so the Pair falls back") + } +} From a1e97c94ac6eb021de3a357174a643824c5c6fb6 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 00:42:05 +0400 Subject: [PATCH 2/3] The workstation transcribes, homesrv is the floor (V-486) Same arrangement as llm.Pair and for the same reason. The microphone is at workpc, the card there has 16GB, and CrisperWhisper 2.0 turbo scores 10.4% WER in Russian against 27.5% for the ggml-small.bin homesrv loads. The workstation is never assumed up: it sleeps, and the card is often held. Admission is a cached atomic written only by the prober, so no voice turn ever waits on a machine that may be asleep. Speech-to-text has only the silent half of the degradation rule. A worse transcript is still a turn, so there is nothing to name a gap about and Transcribe always falls back. That is the whole difference from llm.Pair, which also carries CompleteRemote for callers that must refuse instead. A remote that dies mid-request corrects the cache and falls back in the same turn, which is what TestPairFallsBackWhenRemoteFails pins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- internal/stt/pair.go | 157 ++++++++++++++++++++++++++++++++++++++ internal/stt/pair_test.go | 143 ++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 internal/stt/pair.go create mode 100644 internal/stt/pair_test.go diff --git a/internal/stt/pair.go b/internal/stt/pair.go new file mode 100644 index 0000000..2001ed6 --- /dev/null +++ b/internal/stt/pair.go @@ -0,0 +1,157 @@ +package stt + +import ( + "context" + "errors" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/kami/maven/internal/audio" +) + +// Pair — a preferred transcriber on the workstation, with mavsttd as the floor. +// +// Same arrangement as llm.Pair and for the same reason. The microphone is at +// workpc, the card there has 16GB, and CrisperWhisper 2.0 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). The workstation is +// never assumed up: it sleeps, and the card is often held by a training run. +// +// Speech-to-text has only the silent half of the degradation rule. A worse +// transcript is still a turn, and there is nothing to name a gap about, so +// Transcribe always falls back. That is the whole difference from llm.Pair, +// which also carries CompleteRemote for callers that must refuse instead. +type Pair struct { + remote Transcriber + floor Transcriber + + // up — the cached admission answer, written only by the prober and read by + // every turn. A voice turn must never wait on a machine that may be asleep. + up atomic.Bool + + health string + interval time.Duration + http *http.Client + stop chan struct{} + stopOnce sync.Once +} + +const ( + probeTimeout = 2 * time.Second + defaultProbeInterval = 15 * time.Second +) + +// ErrNoFloor — a Pair was built with no local transcriber to fall back to. A +// configuration mistake: the floor is what makes the remote optional. +var ErrNoFloor = errors.New("stt: no floor transcriber") + +// NewPair builds the two-transcriber arrangement. remote may be nil, which is +// the unconfigured deploy: every turn goes to the floor and nothing probes. +func NewPair(remote, floor Transcriber, health string, interval time.Duration) *Pair { + if interval <= 0 { + // The config normalises this, so a zero here is a caller that built the + // Pair directly. Panicking in a ticker is the wrong way to say so. + interval = defaultProbeInterval + } + return &Pair{ + remote: remote, + floor: floor, + health: health, + interval: interval, + http: &http.Client{Timeout: probeTimeout}, + stop: make(chan struct{}), + } +} + +// Start begins probing. The first probe runs before the first tick, so a +// workstation that is already up serves the first utterance rather than the +// second. Safe with a nil remote. +func (p *Pair) Start(ctx context.Context) { + if p.remote == nil || p.health == "" { + return + } + go func() { + p.probe(ctx) + t := time.NewTicker(p.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-p.stop: + return + case <-t.C: + p.probe(ctx) + } + } + }() +} + +// Stop ends the prober. Idempotent and safe from two goroutines. +func (p *Pair) Stop() { + p.stopOnce.Do(func() { close(p.stop) }) +} + +// Available reports whether the workstation will transcribe right now. +func (p *Pair) Available() bool { + return p.remote != nil && p.up.Load() +} + +func (p *Pair) probe(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.health, nil) + if err != nil { + p.set(false) + return + } + resp, err := p.http.Do(req) + if err != nil { + p.set(false) + return + } + defer resp.Body.Close() + p.set(resp.StatusCode == http.StatusOK) +} + +// set records the admission answer and logs only transitions. A machine that +// sleeps nightly would otherwise write one line per interval forever. +func (p *Pair) set(up bool) { + if p.up.Swap(up) == up { + return + } + if up { + log.Printf("stt: workstation transcriber available at %s", p.health) + } else { + log.Print("stt: workstation transcriber unavailable, falling back to mavsttd") + } +} + +// Transcribe sends the audio to the workstation when it will take work, and to +// mavsttd otherwise. A remote that fails mid-request falls back too, because +// the admission answer is a cache and can be one interval out of date. +// +// Killing the remote mid-session must not drop the turn. That is the whole +// point of the floor, and it is what TestPairFallsBackWhenRemoteFails pins. +func (p *Pair) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) { + if p.floor == nil { + return "", 0, ErrNoFloor + } + if p.Available() { + text, conf, err := p.remote.Transcribe(ctx, a) + if err == nil { + log.Print("stt: transcribed on the workstation") + return text, conf, nil + } + // The cached answer was wrong. Correct it now rather than sending the + // next utterance into the same hole, then fall back. + p.set(false) + log.Printf("stt: workstation failed mid-request, falling back: %v", err) + } + return p.floor.Transcribe(ctx, a) +} + +var _ Transcriber = (*Pair)(nil) diff --git a/internal/stt/pair_test.go b/internal/stt/pair_test.go new file mode 100644 index 0000000..b14b241 --- /dev/null +++ b/internal/stt/pair_test.go @@ -0,0 +1,143 @@ +package stt + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/kami/maven/internal/audio" +) + +// scripted — a Transcriber that answers with a fixed text, or fails. +type scripted struct { + text string + err error + calls atomic.Int32 +} + +func (s *scripted) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) { + s.calls.Add(1) + if s.err != nil { + return "", 0, s.err + } + return s.text, 0.9, nil +} + +func sample() audio.Audio { + return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)} +} + +// up builds a Pair whose admission answer is already true, without probing. +func up(remote, floor Transcriber) *Pair { + p := NewPair(remote, floor, "", time.Minute) + p.up.Store(true) + return p +} + +func TestPairPrefersTheWorkstation(t *testing.T) { + t.Parallel() + remote := &scripted{text: "с рабочей станции"} + floor := &scripted{text: "с homesrv"} + text, _, err := up(remote, floor).Transcribe(context.Background(), sample()) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if text != "с рабочей станции" { + t.Fatalf("want the remote transcript, got %q", text) + } + if floor.calls.Load() != 0 { + t.Fatalf("floor was called %d times, want 0", floor.calls.Load()) + } +} + +// The turn is what matters. A remote that dies mid-session must cost a worse +// transcript and nothing else. This is the V-486 bar. +func TestPairFallsBackWhenRemoteFails(t *testing.T) { + t.Parallel() + remote := &scripted{err: errors.New("connection refused")} + floor := &scripted{text: "с homesrv"} + p := up(remote, floor) + + text, conf, err := p.Transcribe(context.Background(), sample()) + if err != nil { + t.Fatalf("a failed remote must not fail the turn: %v", err) + } + if text != "с homesrv" { + t.Fatalf("want the floor transcript, got %q", text) + } + if conf != 0.9 { + t.Fatalf("want the floor confidence, got %v", conf) + } + if p.Available() { + t.Fatal("a failed request must correct the cached admission answer") + } + + // The next utterance goes straight to the floor rather than into the + // same hole. + if _, _, err := p.Transcribe(context.Background(), sample()); err != nil { + t.Fatalf("second turn: %v", err) + } + if remote.calls.Load() != 1 { + t.Fatalf("remote called %d times, want 1", remote.calls.Load()) + } +} + +func TestPairWithNoRemoteIsTheFloor(t *testing.T) { + t.Parallel() + floor := &scripted{text: "с homesrv"} + p := NewPair(nil, floor, "", time.Minute) + p.Start(context.Background()) // no health url, so this is a no-op + if p.Available() { + t.Fatal("an unconfigured remote is never available") + } + text, _, err := p.Transcribe(context.Background(), sample()) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if text != "с homesrv" { + t.Fatalf("want the floor transcript, got %q", text) + } +} + +func TestPairWithNoFloorRefuses(t *testing.T) { + t.Parallel() + _, _, err := NewPair(nil, nil, "", time.Minute).Transcribe(context.Background(), sample()) + if !errors.Is(err, ErrNoFloor) { + t.Fatalf("want ErrNoFloor, got %v", err) + } +} + +func TestPairProbeReadsHealth(t *testing.T) { + t.Parallel() + var ok atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if !ok.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + p := NewPair(&scripted{text: "remote"}, &scripted{text: "floor"}, srv.URL, time.Minute) + p.probe(context.Background()) + if p.Available() { + t.Fatal("a 503 means the card is busy, so the workstation is not available") + } + ok.Store(true) + p.probe(context.Background()) + if !p.Available() { + t.Fatal("a 200 means the workstation will take work") + } +} + +func TestPairStopIsIdempotent(t *testing.T) { + t.Parallel() + p := NewPair(nil, &scripted{}, "", time.Minute) + p.Stop() + p.Stop() +} From cc32c2c4ab4f19d702526d64a6097e2a74a5842d Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 00:42:15 +0400 Subject: [PATCH 3/3] 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 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- cmd/mavend/sttseam_test.go | 43 +++++++++++++++++++ cmd/mavend/voicewire.go | 48 +++++++++++++++++++++- internal/config/workstation.go | 75 ++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 cmd/mavend/sttseam_test.go 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" }