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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
This commit is contained in:
2026-08-09 00:41:55 +04:00
parent 50c6637c1b
commit c7f59e48f4
2 changed files with 181 additions and 0 deletions
+92
View File
@@ -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)
+89
View File
@@ -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")
}
}