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

+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)