feat(voice): per-account choice between Whisper and the browser; STT_ALLOW_BROWSER
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m40s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55112b940c
commit
a927a94a95
21 files changed
+288
-15
No files matched your search
@@ -49,7 +49,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"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},
|
||||
// browserAllowed: whether people may pick the browser's recognition instead of the Whisper server
|
||||
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds, "browserAllowed": !s.cfg.Speech.NoBrowser},
|
||||
// Public so the login screen can link Impressum and Datenschutz
|
||||
"legal": map[string]interface{}{
|
||||
"imprintUrl": s.cfg.Legal.ImprintURL,
|
||||
@@ -404,6 +405,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
ns := s.store.NotificationSettingsOf(userID)
|
||||
user.NotificationSettings = &ns
|
||||
user.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
||||
user.VoiceInput = s.store.VoiceInput(userID)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user": user,
|
||||
"stats": stats,
|
||||
@@ -515,6 +517,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
NotificationSettings *domain.NotificationSettings `json:"notificationSettings"`
|
||||
// Days after which completed tasks are deleted (done_cleanup.go)
|
||||
DoneRetentionDays *int `json:"doneRetentionDays"`
|
||||
// Voice input: "" (Whisper server, the default) or "browser"
|
||||
VoiceInput *string `json:"voiceInput"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -607,9 +611,16 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.VoiceInput != nil {
|
||||
if err := s.store.SetVoiceInput(userID, *req.VoiceInput); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
ns := s.store.NotificationSettingsOf(userID)
|
||||
u.NotificationSettings = &ns
|
||||
u.DoneRetentionDays = s.store.DoneRetentionDays(userID)
|
||||
u.VoiceInput = s.store.VoiceInput(userID)
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ type SpeechConfig struct {
|
||||
Language string
|
||||
APIKey string
|
||||
Disabled bool // STT_DISABLED=true hides voice input everywhere
|
||||
NoBrowser bool // STT_ALLOW_BROWSER=false forbids the browser's recognition (it sends audio to Google/Microsoft)
|
||||
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
|
||||
@@ -102,6 +103,8 @@ func (s SpeechConfig) Mode(demo bool) string {
|
||||
return "off"
|
||||
case s.URL != "" && !demo:
|
||||
return "server"
|
||||
case s.NoBrowser:
|
||||
return "off"
|
||||
default:
|
||||
return "browser"
|
||||
}
|
||||
@@ -266,6 +269,7 @@ func Load() *Config {
|
||||
Language: getEnv("STT_LANGUAGE", "de"),
|
||||
APIKey: getEnv("STT_API_KEY", ""),
|
||||
Disabled: strings.ToLower(getEnv("STT_DISABLED", "false")) == "true",
|
||||
NoBrowser: strings.ToLower(getEnv("STT_ALLOW_BROWSER", "true")) == "false",
|
||||
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),
|
||||
|
||||
@@ -120,3 +120,24 @@ func TestCustomLinkValidation(t *testing.T) {
|
||||
t.Errorf("expected empty custom link when url is empty, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// STT_ALLOW_BROWSER=false: without a Whisper server (or in the demo) there is no voice input at all
|
||||
func TestSpeechModeWithoutBrowser(t *testing.T) {
|
||||
cases := []struct {
|
||||
cfg SpeechConfig
|
||||
demo bool
|
||||
want string
|
||||
}{
|
||||
{SpeechConfig{URL: "http://whisper:8000"}, false, "server"},
|
||||
{SpeechConfig{URL: "http://whisper:8000", NoBrowser: true}, false, "server"},
|
||||
{SpeechConfig{}, false, "browser"},
|
||||
{SpeechConfig{NoBrowser: true}, false, "off"},
|
||||
{SpeechConfig{URL: "http://whisper:8000", NoBrowser: true}, true, "off"},
|
||||
{SpeechConfig{URL: "http://whisper:8000", Disabled: true}, false, "off"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.cfg.Mode(c.demo); got != c.want {
|
||||
t.Errorf("%+v demo=%v: got %q, want %q", c.cfg, c.demo, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,8 @@ type User struct {
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
||||
// DoneRetentionDays: completed tasks are deleted this many days after ticking them off (0 = never)
|
||||
DoneRetentionDays int `json:"doneRetentionDays"`
|
||||
// VoiceInput: "" uses the server's Whisper (when set up), "browser" the browser's recognition
|
||||
VoiceInput string `json:"voiceInput"`
|
||||
}
|
||||
|
||||
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
|
||||
|
||||
@@ -35,6 +35,7 @@ var migrations = []migration{
|
||||
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
|
||||
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
||||
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
|
||||
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package storage
|
||||
|
||||
import "errors"
|
||||
|
||||
// Everyone picks how their voice input works: the server's Whisper ("", the default) or the browser's
|
||||
// recognition ("browser"). The server only offers the choice when it has a Whisper server and the
|
||||
// browser is allowed (STT_ALLOW_BROWSER); the app applies it (lib/voice.ts).
|
||||
|
||||
var ErrInvalidVoiceInput = errors.New("Ungültige Spracheingabe (erlaubt: Whisper-Server oder Browser)")
|
||||
|
||||
func (s *Store) migrateVoiceInput() error {
|
||||
return s.addColumnIfMissing("users", "voice_input", "TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
|
||||
// VoiceInput returns the user's choice: "" or "browser".
|
||||
func (s *Store) VoiceInput(userID string) string {
|
||||
var v string
|
||||
_ = s.db.QueryRow(`SELECT voice_input FROM users WHERE id = ?`, userID).Scan(&v)
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Store) SetVoiceInput(userID, v string) error {
|
||||
if v != "" && v != "browser" {
|
||||
return ErrInvalidVoiceInput
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE users SET voice_input = ? WHERE id = ?`, v, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVoiceInputChoice(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "voice.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
if v := store.VoiceInput(anna.ID); v != "" {
|
||||
t.Fatalf("default should be the Whisper server (\"\"), got %q", v)
|
||||
}
|
||||
if err := store.SetVoiceInput(anna.ID, "browser"); err != nil || store.VoiceInput(anna.ID) != "browser" {
|
||||
t.Fatalf("browser not stored: %v", err)
|
||||
}
|
||||
if err := store.SetVoiceInput(anna.ID, "google"); err == nil {
|
||||
t.Fatal("unknown values must be refused")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user