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
158 lines
4.6 KiB
Go
158 lines
4.6 KiB
Go
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)
|