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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
This commit is contained in:
2026-08-09 00:42:05 +04:00
parent c7f59e48f4
commit a1e97c94ac
2 changed files with 300 additions and 0 deletions
+143
View File
@@ -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()
}