From a927a94a95eca8ee66f75ff0205096a3605857a7 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 15:19:20 +0100 Subject: [PATCH] feat(voice): per-account choice between Whisper and the browser; STT_ALLOW_BROWSER Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 + CHANGELOG.md | 9 +- Doku/architecture.md | 1 + README.md | 2 +- docker-compose.yml | 1 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.tsx | 3 + .../features/settings/UserSettingsView.tsx | 85 ++++++++++++++++++- .../lib/__tests__/voice.preference.test.ts | 41 +++++++++ frontend/src/lib/api.ts | 1 + frontend/src/lib/types.ts | 4 +- frontend/src/lib/useVoiceInput.ts | 17 ++-- frontend/src/lib/voice.ts | 36 +++++++- internal/api/handlers_auth.go | 13 ++- internal/config/config.go | 4 + internal/config/config_test.go | 21 +++++ internal/domain/models.go | 2 + internal/storage/migrations.go | 1 + internal/storage/voice_input.go | 28 ++++++ internal/storage/voice_input_test.go | 24 ++++++ 21 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/__tests__/voice.preference.test.ts create mode 100644 internal/storage/voice_input.go create mode 100644 internal/storage/voice_input_test.go diff --git a/.env.example b/.env.example index 79cae6d..09e1e57 100644 --- a/.env.example +++ b/.env.example @@ -137,6 +137,10 @@ STT_LANGUAGE=de STT_API_KEY= # true = Mikrofon überall ausblenden, auch die Browser-Erkennung. STT_DISABLED=false +# false = die Spracherkennung des Browsers ist verboten (Chrome/Edge schicken die Aufnahme an Google +# bzw. Microsoft). Mit Whisper-Server entfällt dann die Auswahl Whisper/Browser in den Kontoeinstellungen; +# ohne Whisper-Server gibt es dann keine Spracheingabe. +STT_ALLOW_BROWSER=true # Schutzwerte für den Server-Modus # Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst. diff --git a/CHANGELOG.md b/CHANGELOG.md index c9a261f..270b01e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.42.0] - 2026-11-01 + +### Added +- Spracheingabe pro Person: Mit eigenem Whisper-Server wählt jede Person in den Kontoeinstellungen unter „Spracheingabe“, ob ihr Mikrofon den Whisper-Server (Standard) oder die Erkennung des Browsers nutzt. Die Wahl gilt sofort auf allen Geräten. +- `STT_ALLOW_BROWSER=false` verbietet die Spracherkennung des Browsers (Chrome und Edge schicken die Aufnahme an Google bzw. Microsoft), z. B. für den Einsatz in Firmen. Mit Whisper-Server entfällt dann die Auswahl, ohne Whisper-Server gibt es dann keine Spracheingabe. + ## [0.41.0] - 2026-11-01 ### Added @@ -686,7 +692,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...dev +[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0 [0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0 [0.40.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...v0.40.1 [0.40.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...v0.40.0 diff --git a/Doku/architecture.md b/Doku/architecture.md index 46329a2..f5c1e22 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -81,6 +81,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser | | `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt | | `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus | +| `STT_ALLOW_BROWSER` | `true` | `false` verbietet die Spracherkennung des Browsers (Chrome/Edge schicken die Aufnahme an Google/Microsoft). Mit Whisper-Server entfällt dann die Auswahl „Whisper-Server / Browser“ in den Kontoeinstellungen; ohne Whisper-Server (und in der Demo) gibt es dann keine Spracheingabe. Mit `true` wählt jede Person selbst, Standard ist der Whisper-Server. | | `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme | | `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten | | `STT_VAD_FILTER` | `true` | Bittet den Whisper-Server, Stille zu überspringen (`vad_filter`, speaches); weniger erfundene Wiederholungen. `false`, falls ein Server das Feld ablehnt. Unabhängig davon entfernt MindSift eine wörtlich wiederholte Schlusspassage. | diff --git a/README.md b/README.md index 9042e5a..4d09f95 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.41.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.42.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/docker-compose.yml b/docker-compose.yml index 2ed8e99..9cd80ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,7 @@ services: - STT_LANGUAGE=${STT_LANGUAGE:-de} - STT_API_KEY=${STT_API_KEY:-} - STT_DISABLED=${STT_DISABLED:-false} + - STT_ALLOW_BROWSER=${STT_ALLOW_BROWSER:-true} - STT_MAX_SECONDS=${STT_MAX_SECONDS:-600} - STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25} - STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d5c237d..68a4e97 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.41.0", + "version": "0.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.41.0", + "version": "0.42.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 2f3a4d4..59ab709 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.41.0", + "version": "0.42.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dccc598..b733e86 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -78,6 +78,7 @@ import { useUpdateCheck } from './lib/useUpdateCheck'; import { consumePendingShares, formatSharedThought } from './lib/shareTarget'; import { APP_VERSION } from './version'; import { readViewSession, writeViewSession } from './lib/viewSession'; +import { setVoicePreference } from './lib/voice'; const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const; type ActiveTab = (typeof APP_TABS)[number]; @@ -172,6 +173,8 @@ export const App: React.FC = () => { return tab && APP_TABS.includes(tab) ? tab : 'triage'; }); useEffect(() => writeViewSession('app', { tab: activeTab }), [activeTab]); + // Whisper or the browser for voice input: the account's choice (lib/voice.ts) + useEffect(() => setVoicePreference(user?.voiceInput), [user?.voiceInput]); useEffect(() => { if (user && user.role !== 'admin' && activeTab === 'admin_settings') setActiveTab('triage'); }, [user, activeTab]); diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 6d8df82..418a45b 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -18,6 +18,7 @@ import { Pencil, Copy, Archive, + Mic, } from 'lucide-react'; import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; @@ -25,6 +26,7 @@ import { ServerBackupsCard } from './ServerBackupsCard'; import { saveBlob } from '../../lib/download'; import { api, clearToken, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; +import { loadVoiceConfig, voiceChoiceAvailable } from '../../lib/voice'; import { AVAILABLE_THEMES, CornerStyle, @@ -66,7 +68,7 @@ interface UserSettingsViewProps { onBack: () => void; } -type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks' | 'data'; +type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'webhooks' | 'data'; export function buildNotificationSettings(user: User): NotificationSettings { const hasLegacy = Array.isArray(user.alarmChannels); @@ -132,6 +134,20 @@ export const UserSettingsView: React.FC = ({ onBack, }) => { const [section, setSection] = useState('profile'); + // Voice input: the choice Whisper/browser only exists with a Whisper server that allows the browser + const [voiceChoice, setVoiceChoice] = useState(false); + const [voiceSaving, setVoiceSaving] = useState(false); + useEffect(() => { + void loadVoiceConfig().then(() => setVoiceChoice(voiceChoiceAvailable())); + }, []); + const handleChangeVoiceInput = async (next: '' | 'browser') => { + setVoiceSaving(true); + try { + onUserUpdated(await api.updateSettings({ voiceInput: next })); + } finally { + setVoiceSaving(false); + } + }; const [legal, setLegal] = useState(); // --- Danger zone --- @@ -642,6 +658,23 @@ export const UserSettingsView: React.FC = ({ + {voiceChoice && ( + + )} + + ); + })} + + + )} + {/* ================================================================= */} {/* SECTION 5: EXPORT & IMPORT */} {/* ================================================================= */} diff --git a/frontend/src/lib/__tests__/voice.preference.test.ts b/frontend/src/lib/__tests__/voice.preference.test.ts new file mode 100644 index 0000000..e2fab0f --- /dev/null +++ b/frontend/src/lib/__tests__/voice.preference.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const getAuthStatus = vi.fn(); +vi.mock('../api', () => ({ api: { getAuthStatus: (...a: any[]) => getAuthStatus(...a) } })); + +import { loadVoiceConfig, resetVoiceConfig, setVoicePreference, voiceChoiceAvailable } from '../voice'; + +// Recording and recognition as far as jsdom needs them to count as available +(window as any).MediaRecorder = function () {}; +Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: vi.fn() }, configurable: true }); +(window as any).webkitSpeechRecognition = function () {}; + +afterEach(() => { + resetVoiceConfig(); + getAuthStatus.mockReset(); +}); + +describe('voice input: Whisper or browser per account', () => { + it('uses the browser when the account picked it and the server allows it', async () => { + getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600, browserAllowed: true } }); + expect((await loadVoiceConfig()).mode).toBe('server'); + expect(voiceChoiceAvailable()).toBe(true); + setVoicePreference('browser'); + expect((await loadVoiceConfig()).mode).toBe('browser'); + setVoicePreference(''); + expect((await loadVoiceConfig()).mode).toBe('server'); + }); + + it('keeps Whisper when the server forbids the browser (STT_ALLOW_BROWSER=false)', async () => { + getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600, browserAllowed: false } }); + setVoicePreference('browser'); + expect((await loadVoiceConfig()).mode).toBe('server'); + expect(voiceChoiceAvailable()).toBe(false); + }); + + it('offers no choice without a Whisper server', async () => { + getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600, browserAllowed: true } }); + await loadVoiceConfig(); + expect(voiceChoiceAvailable()).toBe(false); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ac4224d..9458425 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -301,6 +301,7 @@ export const api = { notifyBeforeMinutes?: number; notificationSettings?: NotificationSettings; doneRetentionDays?: number; + voiceInput?: '' | 'browser'; }): Promise { return request('/api/v1/auth/settings', { method: 'PATCH', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a3fbba6..2115ce9 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -31,7 +31,7 @@ export interface AuthStatus { customLinkUrl?: string; }; // Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment) - voice?: { mode: VoiceMode; maxSeconds: number }; + voice?: { mode: VoiceMode; maxSeconds: number; browserAllowed?: boolean }; } export type VoiceMode = 'server' | 'browser' | 'off'; @@ -113,6 +113,8 @@ export interface User { notificationSettings?: NotificationSettings; // Completed tasks are deleted this many days after ticking them off (0 = never) doneRetentionDays?: number; + // Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition + voiceInput?: '' | 'browser'; organizationId?: string; createdAt: string; } diff --git a/frontend/src/lib/useVoiceInput.ts b/frontend/src/lib/useVoiceInput.ts index 54ce8d4..389bd80 100644 --- a/frontend/src/lib/useVoiceInput.ts +++ b/frontend/src/lib/useVoiceInput.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from './api'; -import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, joinSpeechResults, loadVoiceConfig, VoiceRecorder } from './voice'; +import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, joinSpeechResults, loadVoiceConfig, VOICE_CONFIG_EVENT, VoiceRecorder } from './voice'; export type VoiceState = 'idle' | 'listening' | 'transcribing'; @@ -63,12 +63,17 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals useEffect(() => { mountedRef.current = true; - void loadVoiceConfig().then((cfg) => { - if (!mountedRef.current) return; - setMode(cfg.mode); - maxSecondsRef.current = cfg.maxSeconds; - }); + const load = () => + void loadVoiceConfig().then((cfg) => { + if (!mountedRef.current) return; + setMode(cfg.mode); + maxSecondsRef.current = cfg.maxSeconds; + }); + load(); + // The account setting (Whisper or browser) can change while this button is on screen + window.addEventListener(VOICE_CONFIG_EVENT, load); return () => { + window.removeEventListener(VOICE_CONFIG_EVENT, load); mountedRef.current = false; clearAllTimers(); recorderRef.current?.cancel(); diff --git a/frontend/src/lib/voice.ts b/frontend/src/lib/voice.ts index 40a4903..43f1cb9 100644 --- a/frontend/src/lib/voice.ts +++ b/frontend/src/lib/voice.ts @@ -3,6 +3,8 @@ // server record with MediaRecorder, the own Whisper server turns it into text after stopping // browser the browser's speech recognition (Chrome, Edge, Safari; text appears while speaking) // off no voice input +// With a Whisper server everyone may pick the browser instead (account setting), unless the server +// forbids it (STT_ALLOW_BROWSER=false). import { api } from './api'; import type { VoiceMode } from './types'; @@ -15,6 +17,33 @@ export interface VoiceConfig { let configPromise: Promise | null = null; let loadedConfig: VoiceConfig | null = null; +let serverMode: VoiceMode | undefined; +let browserAllowed = true; +let preference: '' | 'browser' = ''; + +/** Fired when the effective mode changes (the account setting was loaded or changed). */ +export const VOICE_CONFIG_EVENT = 'mindsift:voice-config'; + +function applyPreference(mode: VoiceMode | undefined): VoiceMode | undefined { + return mode === 'server' && browserAllowed && preference === 'browser' ? 'browser' : mode; +} + +/** The account's choice ('' = Whisper server, 'browser'); set when the user is loaded or changes it. */ +export function setVoicePreference(next: '' | 'browser' | undefined): void { + const value = next === 'browser' ? 'browser' : ''; + if (value === preference) return; + preference = value; + if (loadedConfig) { + loadedConfig = { ...loadedConfig, mode: effectiveMode(applyPreference(serverMode)) }; + configPromise = Promise.resolve(loadedConfig); + } + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent(VOICE_CONFIG_EVENT)); +} + +/** Whether the account may choose between the Whisper server and the browser. */ +export function voiceChoiceAvailable(): boolean { + return serverMode === 'server' && browserAllowed; +} export function browserRecognition(): any { if (typeof window === 'undefined') return null; @@ -38,7 +67,9 @@ export function loadVoiceConfig(): Promise { configPromise = api .getAuthStatus() .then((st) => { - loadedConfig = { mode: effectiveMode(st.voice?.mode), maxSeconds: st.voice?.maxSeconds || 600 }; + serverMode = st.voice?.mode; + browserAllowed = st.voice?.browserAllowed !== false; + loadedConfig = { mode: effectiveMode(applyPreference(serverMode)), maxSeconds: st.voice?.maxSeconds || 600 }; return loadedConfig; }) .catch(() => { @@ -58,6 +89,9 @@ export function currentVoiceConfig(): VoiceConfig { export function resetVoiceConfig(): void { configPromise = null; loadedConfig = null; + serverMode = undefined; + browserAllowed = true; + preference = ''; } // Containers in order of preference; Safari only records MP4/AAC diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index bfc97c7..7c1ed25 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -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) } diff --git a/internal/config/config.go b/internal/config/config.go index 57ef187..30c2b34 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7bd1156..e2a27d6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } + } +} diff --git a/internal/domain/models.go b/internal/domain/models.go index ea6c499..4123f42 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -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. diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 895d70e..4f195cc 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -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 { diff --git a/internal/storage/voice_input.go b/internal/storage/voice_input.go new file mode 100644 index 0000000..4013c4c --- /dev/null +++ b/internal/storage/voice_input.go @@ -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 +} diff --git a/internal/storage/voice_input_test.go b/internal/storage/voice_input_test.go new file mode 100644 index 0000000..0580471 --- /dev/null +++ b/internal/storage/voice_input_test.go @@ -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") + } +}