feat(voice): all microphone buttons use the server's speech recognition
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s

- New useVoiceInput hook (lib/voice.ts): "server" records with MediaRecorder
  and sends to /voice/transcribe, "browser" keeps the live recognition,
  "off" or missing browser support hides the microphone.
- Quick dump bar, mobile capture sheet and hold-to-record use it; live
  waves from the real input level (VoiceWave) and "Wird erkannt…".
- Recordings stop after STT_MAX_SECONDS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 22:23:48 +01:00
1 parent 7801cd13b9
commit 7fa21e485b
9 files changed
+753 -248

No files matched your search

+65 -52
View File
@@ -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<PriorityLevel>('normal');
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const mobileRecognitionRef = useRef<any>(null);
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(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'
}
/>
<button
type="button"
onClick={toggleMobileVoice}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title="Spracheingabe"
>
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
</button>
{mobileVoice.available && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
{mobileVoiceActive && mobileVoice.mode === 'server' && (
<VoiceWave getLevel={mobileVoice.level} bars={7} className="h-4 text-accent" />
)}
<button
type="button"
onClick={toggleMobileVoice}
disabled={mobileVoice.state === 'transcribing'}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? mobileVoice.mode === 'server'
? 'bg-status-err text-on-accent'
: 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title={mobileVoiceActive ? 'Aufnahme beenden' : 'Spracheingabe'}
>
{mobileVoice.state === 'transcribing' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : mobileVoiceActive ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
</button>
</div>
)}
</div>
</div>