diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac0846..b04f2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` bringt den Dienst `whisper` als Profil mit, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung. - API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`. +### Changed +- Spracheingabe in Quick Dump, im mobilen Erfassen-Sheet und beim Halten des +-Knopfs folgt der Server-Einstellung: mit eigenem Whisper-Server funktioniert sie in allen Browsern (auch Firefox), mit Live-Wellen während der Aufnahme und „Wird erkannt…“ danach. Ohne Unterstützung im Browser oder mit `STT_DISABLED=true` ist das Mikrofon ausgeblendet statt einer Fehlermeldung. + ## [0.6.1] - 2026-10-29 ### Fixed diff --git a/Doku/architecture.md b/Doku/architecture.md index 96dc342..db7916d 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -92,7 +92,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in ## Spracherkennung -Nur über Umgebungsvariablen (`STT_*`, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Im Server-Modus nimmt der Browser per `MediaRecorder` auf und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/` bei speaches). +Nur über Umgebungsvariablen (`STT_*`, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Alle Mikrofon-Knöpfe (Quick-Dump-Leiste, mobiles Erfassen-Sheet, Halten des +-Knopfs) nutzen denselben Hook `useVoiceInput` (`frontend/src/lib/voice.ts`, `useVoiceInput.ts`): `server` nimmt auf und zeigt „Wird erkannt…“, `browser` liefert Text schon beim Sprechen, `off` oder ein Browser ohne die nötige Funktion blendet das Mikrofon aus. Im Server-Modus zeigen Wellen den echten Mikrofonpegel (`VoiceWave`), die Aufnahme endet spätestens nach `STT_MAX_SECONDS`. Der Browser nimmt per `MediaRecorder` auf (WebM/Opus, in Safari MP4/AAC) und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/` bei speaches). Docker Compose bringt den optionalen Dienst `whisper` (speaches 0.8.3, CPU, Profil `whisper`) mit; das Modell liegt in `./whisper` (Ordner gehört UID 1000). Gemessen auf einem Desktop mit 16 Kernen (int8) für einen Satz von 4 s: `Systran/faster-whisper-small` 1,6 s, `deepdml/faster-whisper-large-v3-turbo-ct2` 8 s bei besserer Erkennung (RAM ca. 1 bzw. 2 GB). Auf geteilten vCPUs ist mit dem Mehrfachen zu rechnen. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e18a409..28a7672 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { Flame, Mic, MicOff, + Loader2, Search, } from 'lucide-react'; import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types'; @@ -35,6 +36,8 @@ import { disconnectWebSocket, isSharingDisabled, } from './lib/api'; +import { useVoiceInput } from './lib/useVoiceInput'; +import { VoiceWave } from './components/VoiceWave'; import { AVAILABLE_THEMES, customThemeOption, @@ -175,49 +178,31 @@ export const App: React.FC = () => { const [mobileTriageContent, setMobileTriageContent] = useState(''); const [mobileTriagePriority, setMobileTriagePriority] = useState('normal'); const [mobileTriageDueDate, setMobileTriageDueDate] = useState(''); - const [mobileVoiceActive, setMobileVoiceActive] = useState(false); const [isVoiceCaptured, setIsVoiceCaptured] = useState(false); - const mobileRecognitionRef = useRef(null); + const [mobileVoiceError, setMobileVoiceError] = useState(null); const mobileBaseVoiceRef = useRef(''); + // Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts) + const mobileVoice = useVoiceInput({ + onText: (text) => { + if (!text) return; + const base = mobileBaseVoiceRef.current; + setMobileTriageContent(base ? `${base} ${text}` : text); + setIsVoiceCaptured(true); + }, + onError: (message) => { + setMobileVoiceError(message); + window.setTimeout(() => setMobileVoiceError(null), 4000); + }, + }); + const mobileVoiceActive = mobileVoice.state === 'listening'; const toggleMobileVoice = () => { - const SpeechRecognition = - (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; - if (!SpeechRecognition) { - alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.'); - return; - } - if (mobileVoiceActive && mobileRecognitionRef.current) { - mobileRecognitionRef.current.stop(); - setMobileVoiceActive(false); - return; - } - try { + if (mobileVoice.state === 'idle') { mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim(); - const recognition = new SpeechRecognition(); - recognition.lang = 'de-DE'; - recognition.continuous = false; - recognition.interimResults = true; - recognition.onstart = () => { - setMobileVoiceActive(true); - triggerHaptic(20); - }; - recognition.onresult = (event: any) => { - let transcript = ''; - for (let i = 0; i < event.results.length; i++) { - transcript += event.results[i][0].transcript; - } - const base = mobileBaseVoiceRef.current; - setMobileTriageContent(base ? `${base} ${transcript}` : transcript); - setIsVoiceCaptured(true); - }; - recognition.onerror = () => setMobileVoiceActive(false); - recognition.onend = () => setMobileVoiceActive(false); - mobileRecognitionRef.current = recognition; - recognition.start(); - } catch { - setMobileVoiceActive(false); + setMobileVoiceError(null); + triggerHaptic(20); } + mobileVoice.toggle(); }; const handleMobileCreateTriage = async (e: React.FormEvent) => { @@ -249,7 +234,7 @@ export const App: React.FC = () => { setMobileTriagePriority('normal'); setMobileTriageDueDate(''); setIsVoiceCaptured(false); - setMobileVoiceActive(false); + if (mobileVoice.state !== 'idle') mobileVoice.cancel(); setShowMobileCapture(true); }; @@ -1341,21 +1326,49 @@ export const App: React.FC = () => { autoFocus value={mobileTriageContent} onChange={(e) => setMobileTriageContent(e.target.value)} - placeholder="Was geht dir durch den Kopf?…" - className="w-full input-field px-3 py-2 pr-10 text-sm" + placeholder={ + mobileVoiceError + ? mobileVoiceError + : mobileVoiceActive + ? 'Ich höre zu…' + : mobileVoice.state === 'transcribing' + ? 'Wird erkannt…' + : 'Was geht dir durch den Kopf?…' + } + className={ + mobileVoiceActive && mobileVoice.mode === 'server' + ? 'w-full input-field px-3 py-2 pr-24 text-sm' + : 'w-full input-field px-3 py-2 pr-10 text-sm' + } /> - + {mobileVoice.available && ( +
+ {mobileVoiceActive && mobileVoice.mode === 'server' && ( + + )} + +
+ )} diff --git a/frontend/src/components/VoiceWave.tsx b/frontend/src/components/VoiceWave.tsx new file mode 100644 index 0000000..2637f4b --- /dev/null +++ b/frontend/src/components/VoiceWave.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useRef } from 'react'; + +interface VoiceWaveProps { + /** Input level 0..1; null (unknown, e.g. browser recognition) shows a calm idle wave. */ + getLevel: () => number | null; + bars?: number; + /** Bar colour and size come from the caller, e.g. "h-4 text-accent". */ + className?: string; +} + +// Live wave reacting to the voice. Heights are set directly on the bars every frame, so React does +// not re-render 60 times a second. +export const VoiceWave: React.FC = ({ getLevel, bars = 5, className = 'h-4 text-accent' }) => { + const barRefs = useRef<(HTMLSpanElement | null)[]>([]); + const getLevelRef = useRef(getLevel); + getLevelRef.current = getLevel; + + useEffect(() => { + const history: number[] = new Array(bars).fill(0); + let frame = 0; + let raf = 0; + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + const tick = () => { + frame++; + const measured = getLevelRef.current(); + const level = measured ?? 0.25 + 0.2 * Math.sin(frame / 9); + // Newest level enters in the middle and travels outwards + if (frame % 3 === 0) { + history.pop(); + history.unshift(level); + } + const mid = Math.floor(bars / 2); + barRefs.current.forEach((el, i) => { + if (!el) return; + const v = reduceMotion ? 0.5 : history[Math.abs(i - mid)] ?? 0; + el.style.transform = `scaleY(${Math.max(0.15, Math.min(1, v * 1.4))})`; + }); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [bars]); + + return ( +