From 1ca69e94c126168a4e41ed9b1c7ff7317887837e Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sun, 5 Apr 2026 09:59:56 +0200 Subject: [PATCH] Add voice --- frontend/src/App.jsx | 179 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 165 insertions(+), 14 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ab5fe57..7d38afa 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,6 +35,17 @@ function ProgressCard({ item }) { ) } +function getSupportedRecordingMimeType() { + if (typeof MediaRecorder === 'undefined') return '' + const candidates = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/mp4', + 'audio/ogg;codecs=opus', + ] + return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || '' +} + export default function App() { const [students, setStudents] = useState([]) const [selectedStudentId, setSelectedStudentId] = useState('') @@ -45,10 +56,17 @@ export default function App() { const [assessment, setAssessment] = useState(null) const [assessmentAnswer, setAssessmentAnswer] = useState('') const [speaking, setSpeaking] = useState(false) + const [isRecording, setIsRecording] = useState(false) + const [isTranscribing, setIsTranscribing] = useState(false) + const [voiceStatus, setVoiceStatus] = useState('') const recognitionRef = useRef(null) + const mediaRecorderRef = useRef(null) + const mediaStreamRef = useRef(null) + const recordedChunksRef = useRef([]) + const recordingMimeTypeRef = useRef('') const selectedStudent = useMemo( - () => students.find((s) => String(s.id) === String(selectedStudentId)), + () => students.find((student) => String(student.id) === String(selectedStudentId)), [students, selectedStudentId] ) @@ -62,6 +80,15 @@ export default function App() { } }, [selectedStudentId]) + useEffect(() => { + return () => { + if (recognitionRef.current) { + recognitionRef.current.abort() + } + stopMediaStream() + } + }, []) + async function loadStudents() { const res = await fetch(`${API_BASE}/students`) const data = await res.json() @@ -157,12 +184,113 @@ export default function App() { window.speechSynthesis.speak(utterance) } - function startVoiceInput() { - const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition - if (!Recognition) { - alert('La reconnaissance vocale du navigateur n\'est pas disponible ici.') + function stopMediaStream() { + if (mediaStreamRef.current) { + mediaStreamRef.current.getTracks().forEach((track) => track.stop()) + mediaStreamRef.current = null + } + mediaRecorderRef.current = null + recordedChunksRef.current = [] + recordingMimeTypeRef.current = '' + } + + async function transcribeRecording(audioBlob, mimeType) { + const extension = mimeType.includes('mp4') ? 'mp4' : mimeType.includes('ogg') ? 'ogg' : 'webm' + const formData = new FormData() + formData.append('file', new File([audioBlob], `voice-input.${extension}`, { type: mimeType || 'audio/webm' })) + + setIsTranscribing(true) + setVoiceStatus('Transcription en cours...') + + try { + const res = await fetch(`${API_BASE}/transcribe`, { + method: 'POST', + body: formData, + }) + + if (!res.ok) { + const error = await res.json().catch(() => ({})) + throw new Error(error.detail || 'La transcription a échoué.') + } + + const data = await res.json() + setInput(data.text || '') + setVoiceStatus(data.text ? 'Texte dicté prêt à être envoyé.' : 'Aucun texte reconnu.') + } catch (error) { + setVoiceStatus(error.message || 'Impossible de transcrire cet enregistrement.') + } finally { + setIsTranscribing(false) + } + } + + async function startRecordedVoiceInput() { + if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { + setVoiceStatus('La dictée vocale n’est pas prise en charge par ce navigateur.') return } + + const mimeType = getSupportedRecordingMimeType() + if (!mimeType) { + setVoiceStatus('Aucun format audio compatible n’est disponible dans ce navigateur.') + return + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const recorder = new MediaRecorder(stream, { mimeType }) + + mediaStreamRef.current = stream + mediaRecorderRef.current = recorder + recordedChunksRef.current = [] + recordingMimeTypeRef.current = mimeType + + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + recordedChunksRef.current.push(event.data) + } + } + + recorder.onstop = async () => { + const finalMimeType = recordingMimeTypeRef.current || mimeType + const audioBlob = new Blob(recordedChunksRef.current, { type: finalMimeType }) + stopMediaStream() + setIsRecording(false) + + if (audioBlob.size > 0) { + await transcribeRecording(audioBlob, finalMimeType) + } else { + setVoiceStatus('Aucun son détecté. Réessaie en parlant plus près du micro.') + } + } + + recorder.onerror = () => { + setVoiceStatus('Le navigateur a rencontré une erreur pendant l’enregistrement.') + setIsRecording(false) + stopMediaStream() + } + + recorder.start() + setIsRecording(true) + setVoiceStatus('Enregistrement en cours... clique à nouveau pour arrêter.') + } catch { + setVoiceStatus('Accès au micro refusé ou indisponible.') + setIsRecording(false) + stopMediaStream() + } + } + + function stopRecordedVoiceInput() { + const recorder = mediaRecorderRef.current + if (recorder && recorder.state !== 'inactive') { + recorder.stop() + setVoiceStatus('Finalisation de l’enregistrement...') + } + } + + function startBrowserRecognition() { + const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition + if (!Recognition) return false + const recognition = new Recognition() recognition.lang = 'fr-FR' recognition.interimResults = false @@ -170,17 +298,36 @@ export default function App() { recognition.onresult = (event) => { const transcript = event.results[0][0].transcript setInput(transcript) + setVoiceStatus('Texte dicté prêt à être envoyé.') + } + recognition.onerror = () => { + setVoiceStatus('La reconnaissance vocale du navigateur a échoué. Essaie l’enregistrement audio.') + } + recognition.onstart = () => { + setVoiceStatus('Écoute en cours...') } - recognition.onerror = () => {} recognitionRef.current = recognition recognition.start() + return true + } + + async function startVoiceInput() { + if (isRecording) { + stopRecordedVoiceInput() + return + } + + const startedNativeRecognition = startBrowserRecognition() + if (!startedNativeRecognition) { + await startRecordedVoiceInput() + } } return (
) -} +} \ No newline at end of file