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)