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

+57
View File
@@ -43,6 +43,37 @@ type Config struct {
// Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login
Legal LegalTexts
// Speech recognition, configured only by environment (STT_*)
Speech SpeechConfig
}
// SpeechConfig: with STT_URL set, voice input is transcribed by a self-hosted, OpenAI-compatible
// Whisper server (e.g. speaches); otherwise the browser's own speech recognition is used.
type SpeechConfig struct {
URL string // base URL, e.g. http://whisper:8000
Model string
Language string
APIKey string
Disabled bool // STT_DISABLED=true hides voice input everywhere
MaxSeconds int // recordings stop after this long (enforced in the browser)
MaxUploadMB int
MaxParallel int // transcriptions running at the same time; more wait in line
PerUserPerHour int
TimeoutSeconds int
}
// Mode is "server", "browser" or "off". The demo never uses the server, so visitors cannot keep
// its CPU busy.
func (s SpeechConfig) Mode(demo bool) string {
switch {
case s.Disabled:
return "off"
case s.URL != "" && !demo:
return "server"
default:
return "browser"
}
}
type LegalTexts struct {
@@ -179,6 +210,18 @@ func Load() *Config {
SMTPFrom: smtpFrom,
SMTPTLS: smtpTLS,
SMTPSkipVerify: smtpSkipVerify,
Speech: SpeechConfig{
URL: strings.TrimRight(getEnv("STT_URL", ""), "/"),
Model: getEnv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2"),
Language: getEnv("STT_LANGUAGE", "de"),
APIKey: getEnv("STT_API_KEY", ""),
Disabled: strings.ToLower(getEnv("STT_DISABLED", "false")) == "true",
MaxSeconds: envInt("STT_MAX_SECONDS", 600, 5, 3600),
MaxUploadMB: envInt("STT_MAX_UPLOAD_MB", 25, 1, 500),
MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32),
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
},
Legal: LegalTexts{
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
@@ -209,6 +252,20 @@ func getEnv(key, fallback string) string {
return fallback
}
// envInt reads a whole number within [min, max]; anything else falls back to def (with a warning).
func envInt(key string, def, min, max int) int {
raw := getEnv(key, "")
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n < min || n > max {
slog.Warn("Ungültiger Wert, Standard wird verwendet", "variable", key, "value", raw, "default", def)
return def
}
return n
}
func demoResetMinutes() int {
n, err := strconv.Atoi(strings.TrimSpace(getEnv("DEMO_RESET_MINUTES", "20")))
if err != nil || n < 0 {