Merge pull request 'Move STT and TTS to the workstation, where the microphone already is' (#208) from task/486-move-stt-and-tts-to-the-workstation-wher into master
This commit was merged in pull request #208.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user