Move STT and TTS to the workstation, where the microphone already is #208

Merged
claude merged 3 commits from task/486-move-stt-and-tts-to-the-workstation-wher into master 2026-08-08 23:21:52 +02:00
2 changed files with 181 additions and 0 deletions
Showing only changes of commit c7f59e48f4 - Show all commits
+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")
}
}