From c7f59e48f40ac521479bd553d2c565a3ef421fc5 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 9 Aug 2026 00:41:55 +0400 Subject: [PATCH] CrisperWhisper reads audio over HTTP, not a socket (V-486) 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, so it never loads at all. So it runs under transformers on workpc and this is the client. Same stt.Transcriber interface and one method, a second transport rather than a second seam. The body is the PCM itself, because a minute of 16kHz mono is under 2MB raw and the format is fixed by audio.PCM16kMono. Audio is the most sensitive thing that crosses this seam, so the client carries a bearer token. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN --- internal/stt/http.go | 92 +++++++++++++++++++++++++++++++++++++++ internal/stt/http_test.go | 89 +++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 internal/stt/http.go create mode 100644 internal/stt/http_test.go diff --git a/internal/stt/http.go b/internal/stt/http.go new file mode 100644 index 0000000..adf04fd --- /dev/null +++ b/internal/stt/http.go @@ -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) diff --git a/internal/stt/http_test.go b/internal/stt/http_test.go new file mode 100644 index 0000000..85d3289 --- /dev/null +++ b/internal/stt/http_test.go @@ -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") + } +}