feat: add voice-to-text input using Web Speech API

This commit is contained in:
Christian Vidal Wolf
2026-04-11 11:34:57 +02:00
parent 5104dfd59d
commit 0e797e06bb
2 changed files with 116 additions and 3 deletions
+42 -3
View File
@@ -1,8 +1,9 @@
import React, { useState } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react'; import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
import { generateGemini } from '../services/gemini'; import { generateGemini } from '../services/gemini';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
interface EditPanelProps { interface EditPanelProps {
@@ -49,6 +50,9 @@ interface FieldEditorProps {
onGenerate: () => void; onGenerate: () => void;
onChange: (val: string) => void; onChange: (val: string) => void;
onKeyDown: (e: React.KeyboardEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void;
isListening?: boolean;
onToggleVoice?: () => void;
voiceSupported?: boolean;
} }
const FieldEditor = ({ const FieldEditor = ({
@@ -61,7 +65,10 @@ const FieldEditor = ({
isLoading, isLoading,
onGenerate, onGenerate,
onChange, onChange,
onKeyDown onKeyDown,
isListening,
onToggleVoice,
voiceSupported
}: FieldEditorProps) => ( }: FieldEditorProps) => (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -73,6 +80,21 @@ const FieldEditor = ({
Can translate from existing Can translate from existing
</div> </div>
)} )}
{voiceSupported && (
<button
onClick={onToggleVoice}
className={cn(
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
isListening
? "bg-red-600/20 text-red-400 animate-pulse"
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
)}
title={isListening ? "Stop recording" : "Voice input"}
>
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
{isListening ? 'Stop' : 'Voice'}
</button>
)}
<button <button
onClick={onGenerate} onClick={onGenerate}
disabled={isLoading} disabled={isLoading}
@@ -127,6 +149,17 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null); const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set()); const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
const handleSpeechResult = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longDe: prev.longDe + ' ' + transcript }));
}, []);
const { isListening: isListeningDe, start: startListeningDe, stop: stopListeningDe, isSupported: speechSupported } = useSpeechRecognition({ onResult: handleSpeechResult });
const handleSpeechResultEn = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longEn: prev.longEn + ' ' + transcript }));
}, []);
const { isListening: isListeningEn, start: startListeningEn, stop: stopListeningEn } = useSpeechRecognition({ onResult: handleSpeechResultEn, lang: 'en-US' });
const isModified = (field: keyof typeof formData) => { const isModified = (field: keyof typeof formData) => {
const colMap: Record<string, number> = { const colMap: Record<string, number> = {
longDe: COLUMNS.LONG_DE, longDe: COLUMNS.LONG_DE,
@@ -351,6 +384,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
onGenerate={() => handleGenerate('longDe')} onGenerate={() => handleGenerate('longDe')}
onChange={(val) => setFormData(p => ({...p, longDe: val}))} onChange={(val) => setFormData(p => ({...p, longDe: val}))}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
isListening={isListeningDe}
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
voiceSupported={speechSupported}
/> />
<FieldEditor <FieldEditor
title="Long Description (EN)" title="Long Description (EN)"
@@ -363,6 +399,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
onGenerate={() => handleGenerate('longEn')} onGenerate={() => handleGenerate('longEn')}
onChange={(val) => setFormData(p => ({...p, longEn: val}))} onChange={(val) => setFormData(p => ({...p, longEn: val}))}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
isListening={isListeningEn}
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
voiceSupported={speechSupported}
/> />
<FieldEditor <FieldEditor
title="Short Description (DE)" title="Short Description (DE)"
+74
View File
@@ -0,0 +1,74 @@
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseSpeechRecognitionOptions {
onResult: (transcript: string) => void;
lang?: string;
}
interface UseSpeechRecognitionReturn {
isListening: boolean;
start: () => void;
stop: () => void;
isSupported: boolean;
}
export function useSpeechRecognition({ onResult, lang = 'en-US' }: UseSpeechRecognitionOptions): UseSpeechRecognitionReturn {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<any>(null);
const isSupported = typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
useEffect(() => {
if (!isSupported) return;
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = lang;
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
if (event.results[event.resultIndex].isFinal) {
onResult(transcript);
}
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognitionRef.current = recognition;
return () => {
try {
recognition.stop();
} catch (e) {}
};
}, [isSupported, lang, onResult]);
const start = useCallback(() => {
if (!recognitionRef.current || isListening) return;
try {
recognitionRef.current.start();
setIsListening(true);
} catch (e) {}
}, [isListening]);
const stop = useCallback(() => {
if (!recognitionRef.current || !isListening) return;
try {
recognitionRef.current.stop();
setIsListening(false);
} catch (e) {}
}, [isListening]);
return { isListening, start, stop, isSupported };
}