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
+75
View File
@@ -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"
}