Files
MindSift/internal/api/voice_test.go
T
sandrodachandClaude Opus 5.5 7801cd13b9
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m49s
feat(voice): self-hosted speech recognition via Whisper (STT_*)
- STT_URL switches voice input to an OpenAI-compatible Whisper server
  (e.g. speaches); without it the browser's recognition stays. Configured
  by environment only, with limits for upload size, parallel jobs, hourly
  budget per user and timeout. The demo always uses the browser.
- POST /voice/transcribe forwards recordings and returns the text; nothing
  is stored. On startup MindSift waits for the server and asks it to
  download the model if missing.
- Admin card shows the active mode and server state, with a test recording.
- docker-compose: optional "whisper" service (speaches 0.8.3 CPU, profile
  "whisper", models in ./whisper).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:12:57 +01:00

134 lines
5.3 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/sandrodach/mindsift/internal/config"
)
// fakeWhisper answers like speaches: model list and OpenAI-style transcriptions.
func fakeWhisper(t *testing.T, models []string, got *map[string]string, downloads *int32) *httptest.Server {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/models":
var data []map[string]string
for _, m := range models {
data = append(data, map[string]string{"id": m})
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"data": data})
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/models/"):
atomic.AddInt32(downloads, 1)
w.WriteHeader(http.StatusCreated)
case r.Method == http.MethodPost && r.URL.Path == "/v1/audio/transcriptions":
if r.Header.Get("Authorization") != "Bearer geheim" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = r.ParseMultipartForm(10 << 20)
f, _, _ := r.FormFile("file")
audio, _ := io.ReadAll(f)
*got = map[string]string{"model": r.FormValue("model"), "language": r.FormValue("language"), "audio": string(audio)}
_ = json.NewEncoder(w).Encode(map[string]string{"text": " Hallo Welt, wie geht's? "})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(ts.Close)
return ts
}
func voiceUpload(t *testing.T, router http.Handler, token string, audio []byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, _ := mw.CreateFormFile("file", "aufnahme.webm")
_, _ = fw.Write(audio)
_ = mw.Close()
req := httptest.NewRequest("POST", "/api/v1/voice/transcribe", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func voiceServer(t *testing.T, speech config.SpeechConfig) (*Server, string) {
t.Helper()
srv, _, token := setupTestServer(t)
srv.cfg.Speech = speech
srv.voice = newVoiceService(speech.MaxParallel)
return srv, token
}
func TestVoiceTranscribeForwardsToWhisper(t *testing.T) {
var got map[string]string
var downloads int32
whisper := fakeWhisper(t, []string{"Systran/faster-whisper-small"}, &got, &downloads)
srv, token := voiceServer(t, config.SpeechConfig{URL: whisper.URL + "/v1", Model: "Systran/faster-whisper-small", Language: "de",
APIKey: "geheim", MaxSeconds: 600, MaxUploadMB: 1, MaxParallel: 1, PerUserPerHour: 2, TimeoutSeconds: 10})
router := srv.Router()
rec := voiceUpload(t, router, token, []byte("RIFF-audio"))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"text":"Hallo Welt, wie geht's?"`) {
t.Fatalf("transcribe: %d %s", rec.Code, rec.Body.String())
}
if got["model"] != "Systran/faster-whisper-small" || got["language"] != "de" || got["audio"] != "RIFF-audio" {
t.Fatalf("forwarded wrong data: %+v", got)
}
// Hourly budget per user
_ = voiceUpload(t, router, token, []byte("x"))
if rec := voiceUpload(t, router, token, []byte("x")); rec.Code != http.StatusTooManyRequests {
t.Fatalf("over the hourly budget: want 429, got %d", rec.Code)
}
// Model already there: the startup check reports ready and requests no download
st := srv.checkVoice(context.Background())
if !st.Reachable || !st.ModelReady || downloads != 0 {
t.Fatalf("status %+v, downloads %d", st, downloads)
}
// Status for the login screen and the admin card, without the API key
if body := sessionCall(router, "GET", "/api/v1/auth/status", "", "", "").Body.String(); !strings.Contains(body, `"mode":"server"`) {
t.Fatalf("auth status: %s", body)
}
body := sessionCall(router, "GET", "/api/v1/admin/voice", token, "", "").Body.String()
if !strings.Contains(body, `"modelReady":true`) || strings.Contains(body, "geheim") {
t.Fatalf("admin voice status: %s", body)
}
}
func TestVoiceUploadLimitAndModes(t *testing.T) {
var got map[string]string
var downloads int32
whisper := fakeWhisper(t, nil, &got, &downloads)
srv, token := voiceServer(t, config.SpeechConfig{URL: whisper.URL, Model: "m", MaxUploadMB: 1, MaxParallel: 1, PerUserPerHour: 10, TimeoutSeconds: 10})
if rec := voiceUpload(t, srv.Router(), token, bytes.Repeat([]byte("x"), 3<<20)); rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest {
t.Fatalf("too large: want 413/400, got %d", rec.Code)
}
if st := srv.checkVoice(context.Background()); st.ModelReady || !st.Reachable {
t.Fatalf("missing model must be reported: %+v", st)
}
if m := (config.SpeechConfig{URL: "http://whisper:8000"}).Mode(true); m != "browser" {
t.Fatalf("demo must use the browser, got %s", m)
}
if m := (config.SpeechConfig{}).Mode(false); m != "browser" {
t.Fatalf("without URL: browser, got %s", m)
}
if m := (config.SpeechConfig{URL: "x", Disabled: true}).Mode(false); m != "off" {
t.Fatalf("disabled: off, got %s", m)
}
browserSrv, browserToken := voiceServer(t, config.SpeechConfig{MaxParallel: 1, PerUserPerHour: 10})
if rec := voiceUpload(t, browserSrv.Router(), browserToken, []byte("x")); rec.Code != http.StatusNotFound {
t.Fatalf("browser mode: want 404, got %d", rec.Code)
}
}