a1e97c94ac
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
144 lines
3.8 KiB
Go
144 lines
3.8 KiB
Go
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()
|
||
}
|