package stt import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "math" "net/http" "strconv" "strings" "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") // ErrInvalidTranscript means a peer returned HTTP success without a usable // transcript contract. It is an error so Pair can fall back to mavsttd rather // than dropping the turn on a valid-looking 200 response. var ErrInvalidTranscript = errors.New("stt: invalid transcript response") // MaxTranscriptResponseBytes bounds the JSON envelope returned by remote STT. // A transcript is plain text for one utterance, so 64 KiB is already far above // useful output and keeps a malformed LAN peer from allocating without bound. const MaxTranscriptResponseBytes int64 = 64 << 10 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) } raw, err := io.ReadAll(io.LimitReader(resp.Body, MaxTranscriptResponseBytes+1)) if err != nil { return "", 0, fmt.Errorf("stt: read transcript: %w", err) } if int64(len(raw)) > MaxTranscriptResponseBytes { return "", 0, fmt.Errorf("%w: response exceeds %d bytes", ErrInvalidTranscript, MaxTranscriptResponseBytes) } var out httpTranscript if err := json.Unmarshal(raw, &out); err != nil { return "", 0, fmt.Errorf("stt: decode transcript: %w", err) } if out.Text == nil || out.Confidence == nil { return "", 0, fmt.Errorf("%w: text and confidence are required", ErrInvalidTranscript) } text := strings.TrimSpace(*out.Text) if err := validateTranscript(text, *out.Confidence); err != nil { return "", 0, err } return text, *out.Confidence, nil } func validateTranscript(text string, confidence float64) error { if strings.TrimSpace(text) == "" { return fmt.Errorf("%w: text is blank", ErrInvalidTranscript) } if math.IsNaN(confidence) || math.IsInf(confidence, 0) || confidence < 0 || confidence > 1 { return fmt.Errorf("%w: confidence %v is outside [0,1]", ErrInvalidTranscript, confidence) } return nil } var _ Transcriber = (*HTTPTranscriber)(nil)