feat(voice): self-hosted speech recognition via Whisper (STT_*)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m49s

- 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>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 22:12:57 +01:00
1 parent 6a5e82431f
commit 7801cd13b9
18 files changed
+816 -6

No files matched your search

+5 -3
View File
@@ -12,8 +12,10 @@ import (
// with huge uploads. Note sync and imports need more; their handlers apply the same limits again.
const defaultMaxBody = 2 << 20
func bodyLimitFor(path string) int64 {
func (s *Server) bodyLimitFor(path string) int64 {
switch {
case path == "/api/v1/voice/transcribe":
return int64(s.cfg.Speech.MaxUploadMB)<<20 + 1<<20
case strings.HasPrefix(path, "/api/v1/import/"):
return storage.MaxImportUploadBytes + 1<<20
case strings.HasPrefix(path, "/api/v1/docs/") && strings.HasSuffix(path, "/sync"):
@@ -22,10 +24,10 @@ func bodyLimitFor(path string) int64 {
return defaultMaxBody
}
func bodyLimit(next http.Handler) http.Handler {
func (s *Server) bodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.Body != http.NoBody {
r.Body = http.MaxBytesReader(w, r.Body, bodyLimitFor(r.URL.Path))
r.Body = http.MaxBytesReader(w, r.Body, s.bodyLimitFor(r.URL.Path))
}
next.ServeHTTP(w, r)
})
+1 -1
View File
@@ -16,7 +16,7 @@ func TestRequestBodyLimited(t *testing.T) {
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, `{"content":"klein"}`, ""); rec.Code >= 300 {
t.Fatalf("normal body: got %d %s", rec.Code, rec.Body.String())
}
if bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
if srv.bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || srv.bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
t.Fatal("sync and import keep their larger limits")
}
}
+2
View File
@@ -44,6 +44,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds},
// Public so the login screen can link Impressum and Datenschutz
"legal": map[string]interface{}{
"imprintUrl": s.cfg.Legal.ImprintURL,
+8 -1
View File
@@ -33,6 +33,7 @@ type Server struct {
demo demoState // periodic reset in demo mode (demo.go)
streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go)
loginGuard *loginGuard // brute-force protection (loginguard.go)
voice *voiceService // self-hosted speech recognition (voice.go)
}
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
@@ -45,6 +46,10 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
notifier: collab.NewNotifier(),
}
srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection)
srv.voice = newVoiceService(cfg.Speech.MaxParallel)
if srv.voiceMode() == "server" {
go srv.startVoiceCheck()
}
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
wsHub.SetOriginCheck(srv.streamOriginAllowed)
// Next occurrences of repeating tasks appear live on every connected client
@@ -84,7 +89,7 @@ func (s *Server) Router() http.Handler {
r.Use(s.clientIPMiddleware)
r.Use(s.securityHeaders)
r.Use(bodyLimit)
r.Use(s.bodyLimit)
r.Use(s.demoResetGate)
r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)
@@ -126,6 +131,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/auth/me", s.handleMe)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe)
r.Get("/api/v1/auth/sessions", s.handleListSessions)
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
r.Post("/api/v1/auth/logout", s.handleLogout)
@@ -271,6 +277,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings))
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
r.Get("/api/v1/admin/voice", s.handleAdminVoiceStatus)
// Organizations decide who can see whom; only server admins manage them
r.Get("/api/v1/admin/organizations", s.handleAdminGetOrganizations)
+306
View File
@@ -0,0 +1,306 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/textproto"
"path"
"strings"
"sync"
"time"
"github.com/sandrodach/mindsift/internal/auth"
)
// Self-hosted speech recognition: the browser records audio and sends it here, MindSift passes it on
// to an OpenAI-compatible Whisper server (STT_URL, e.g. speaches) and returns the text. Nothing is
// stored. A limited number of transcriptions run at the same time (STT_MAX_PARALLEL), the others
// wait in line; each user has an hourly budget (STT_PER_USER_PER_HOUR). Problems are logged, the
// browser only gets a short message.
type voiceService struct {
slots chan struct{}
mu sync.Mutex
usage map[string][]time.Time // userID -> transcriptions within the last hour
status voiceStatus
checked time.Time
}
// voiceStatus is what the admin page shows about the Whisper server.
type voiceStatus struct {
Reachable bool `json:"reachable"`
ModelReady bool `json:"modelReady"`
Error string `json:"error,omitempty"`
CheckedAt string `json:"checkedAt,omitempty"`
}
func newVoiceService(parallel int) *voiceService {
if parallel < 1 {
parallel = 1
}
return &voiceService{slots: make(chan struct{}, parallel), usage: map[string][]time.Time{}}
}
func (s *Server) voiceMode() string {
return s.cfg.Speech.Mode(s.cfg.DemoMode)
}
// allowVoice counts one transcription for the user; false when the hourly budget is used up.
func (v *voiceService) allowVoice(userID string, perHour int, now time.Time) bool {
v.mu.Lock()
defer v.mu.Unlock()
recent := v.usage[userID][:0]
for _, t := range v.usage[userID] {
if now.Sub(t) < time.Hour {
recent = append(recent, t)
}
}
if len(recent) >= perHour {
v.usage[userID] = recent
return false
}
v.usage[userID] = append(recent, now)
return true
}
// sttEndpoint joins the base URL with an API path; a base URL ending in /v1 works as well.
func (s *Server) sttEndpoint(p string) string {
base := strings.TrimSuffix(s.cfg.Speech.URL, "/v1")
return base + p
}
func (s *Server) sttRequest(ctx context.Context, method, p string, body io.Reader, contentType string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, s.sttEndpoint(p), body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if s.cfg.Speech.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+s.cfg.Speech.APIKey)
}
return http.DefaultClient.Do(req)
}
func (s *Server) handleVoiceTranscribe(w http.ResponseWriter, r *http.Request) {
if s.voiceMode() != "server" {
writeError(w, http.StatusNotFound, "Serverseitige Spracherkennung ist nicht eingerichtet")
return
}
cfg := s.cfg.Speech
userID := auth.GetUserID(r.Context())
if !s.voice.allowVoice(userID, cfg.PerUserPerHour, time.Now()) {
writeError(w, http.StatusTooManyRequests, "Stundenlimit für die Spracherkennung erreicht. Bitte später erneut versuchen.")
return
}
allowLongTransfer(w)
file, header, err := r.FormFile("file")
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Aufnahme zu groß (höchstens %d MB)", cfg.MaxUploadMB))
return
}
writeError(w, http.StatusBadRequest, "Keine Aufnahme im Feld \"file\"")
return
}
defer file.Close()
audio, err := io.ReadAll(file)
if err != nil || len(audio) == 0 {
writeError(w, http.StatusBadRequest, "Aufnahme konnte nicht gelesen werden")
return
}
// Wait for a free slot; the request may be cancelled meanwhile (user stopped waiting)
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(cfg.TimeoutSeconds)*time.Second)
defer cancel()
select {
case s.voice.slots <- struct{}{}:
defer func() { <-s.voice.slots }()
case <-ctx.Done():
slog.Warn("Spracherkennung: keine freie Kapazität innerhalb der Zeitgrenze", "user_id", userID, "parallel", cfg.MaxParallel)
writeError(w, http.StatusServiceUnavailable, "Die Spracherkennung ist gerade ausgelastet. Bitte gleich noch einmal versuchen.")
return
}
started := time.Now()
text, err := s.transcribe(ctx, audio, header.Filename, header.Header.Get("Content-Type"))
if err != nil {
slog.Warn("Spracherkennung fehlgeschlagen", "url", s.sttEndpoint("/v1/audio/transcriptions"), "model", cfg.Model,
"bytes", len(audio), "duration_ms", time.Since(started).Milliseconds(), "error", err)
msg := "Spracherkennung fehlgeschlagen"
if errors.Is(err, context.DeadlineExceeded) {
msg = "Spracherkennung hat zu lange gedauert"
}
writeError(w, http.StatusBadGateway, msg)
return
}
slog.Debug("Spracherkennung", "user_id", userID, "bytes", len(audio), "duration_ms", time.Since(started).Milliseconds())
writeJSON(w, http.StatusOK, map[string]string{"text": text})
}
// transcribe sends the audio to the Whisper server (OpenAI API: POST /v1/audio/transcriptions).
func (s *Server) transcribe(ctx context.Context, audio []byte, filename, contentType string) (string, error) {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if filename == "" {
filename = "aufnahme.webm"
}
part := textproto.MIMEHeader{}
part.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, path.Base(strings.ReplaceAll(filename, `"`, ""))))
if contentType == "" {
contentType = "application/octet-stream"
}
part.Set("Content-Type", contentType)
fw, err := mw.CreatePart(part)
if err != nil {
return "", err
}
if _, err := fw.Write(audio); err != nil {
return "", err
}
_ = mw.WriteField("model", s.cfg.Speech.Model)
if lang := s.cfg.Speech.Language; lang != "" && lang != "auto" {
_ = mw.WriteField("language", lang)
}
_ = mw.WriteField("response_format", "json")
if err := mw.Close(); err != nil {
return "", err
}
resp, err := s.sttRequest(ctx, http.MethodPost, "/v1/audio/transcriptions", &body, mw.FormDataContentType())
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var out struct {
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("unerwartete Antwort: %s", strings.TrimSpace(string(raw)))
}
return strings.TrimSpace(out.Text), nil
}
// checkVoice asks the Whisper server whether it is up and has the model.
func (s *Server) checkVoice(ctx context.Context) voiceStatus {
st := voiceStatus{CheckedAt: time.Now().UTC().Format(time.RFC3339)}
resp, err := s.sttRequest(ctx, http.MethodGet, "/v1/models", nil, "")
if err != nil {
st.Error = err.Error()
return st
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
st.Error = fmt.Sprintf("GET /v1/models: HTTP %d", resp.StatusCode)
return st
}
st.Reachable = true
var list struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
_ = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&list)
for _, m := range list.Data {
if m.ID == s.cfg.Speech.Model {
st.ModelReady = true
}
}
if !st.ModelReady {
st.Error = "Modell ist noch nicht heruntergeladen"
}
return st
}
func (s *Server) setVoiceStatus(st voiceStatus) {
s.voice.mu.Lock()
s.voice.status = st
s.voice.checked = time.Now()
s.voice.mu.Unlock()
}
// startVoiceCheck waits for the Whisper server after startup (it may start later than MindSift) and
// asks it to download the model if it does not have it yet, so the first recording works.
func (s *Server) startVoiceCheck() {
cfg := s.cfg.Speech
slog.Info("Spracherkennung: Server-Modus", "url", cfg.URL, "model", cfg.Model, "language", cfg.Language,
"max_parallel", cfg.MaxParallel, "per_user_per_hour", cfg.PerUserPerHour, "max_upload_mb", cfg.MaxUploadMB)
downloadRequested := false
for attempt := 0; ; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
st := s.checkVoice(ctx)
cancel()
s.setVoiceStatus(st)
switch {
case st.ModelReady:
slog.Info("Spracherkennung bereit", "url", cfg.URL, "model", cfg.Model)
return
case st.Reachable && !downloadRequested:
downloadRequested = true
slog.Info("Spracherkennung: Modell wird heruntergeladen (kann einige Minuten dauern)", "model", cfg.Model)
go s.requestModelDownload()
case !st.Reachable && attempt%6 == 0:
slog.Warn("Spracherkennung: Whisper-Server nicht erreichbar, neuer Versuch folgt", "url", cfg.URL, "error", st.Error)
}
time.Sleep(10 * time.Second)
}
}
func (s *Server) requestModelDownload() {
// Model IDs contain a slash (owner/name); speaches takes them as a path
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
resp, err := s.sttRequest(ctx, http.MethodPost, "/v1/models/"+s.cfg.Speech.Model, nil, "")
if err != nil {
slog.Warn("Spracherkennung: Modell-Download fehlgeschlagen", "model", s.cfg.Speech.Model, "error", err)
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 {
slog.Warn("Spracherkennung: Modell-Download abgelehnt", "model", s.cfg.Speech.Model, "status", resp.StatusCode, "body", strings.TrimSpace(string(raw)))
}
}
// handleAdminVoiceStatus shows the admin which speech recognition is active (configured by
// environment only) and checks the Whisper server right now.
func (s *Server) handleAdminVoiceStatus(w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.Speech
out := map[string]interface{}{
"mode": s.voiceMode(),
"demo": s.cfg.DemoMode,
"maxSeconds": cfg.MaxSeconds,
"maxUploadMb": cfg.MaxUploadMB,
"maxParallel": cfg.MaxParallel,
"perUserPerHour": cfg.PerUserPerHour,
"timeoutSeconds": cfg.TimeoutSeconds,
}
if cfg.URL != "" {
out["url"] = cfg.URL
out["model"] = cfg.Model
out["language"] = cfg.Language
out["apiKeySet"] = cfg.APIKey != ""
}
if s.voiceMode() == "server" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
st := s.checkVoice(ctx)
cancel()
s.setVoiceStatus(st)
out["status"] = st
}
writeJSON(w, http.StatusOK, out)
}
+133
View File
@@ -0,0 +1,133 @@
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)
}
}