From 73a78fd7a537eb55a357967daeb1ce04e05dd5dc Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sun, 5 Apr 2026 10:44:46 +0200 Subject: [PATCH] Add Micro auto --- docker-compose.yml | 51 ------- frontend/src/App.jsx | 355 ++++++++++++++++++++++++++++++------------- 2 files changed, 251 insertions(+), 155 deletions(-) delete mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 79e7a58..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,51 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_DB: tutor - POSTGRES_USER: tutor - POSTGRES_PASSWORD: tutor - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - - backend: - build: ./backend - environment: - OPENAI_API_KEY: ${OPENAI_API_KEY} - DATABASE_URL: postgresql+psycopg2://tutor:tutor@postgres:5432/tutor - REDIS_URL: redis://redis:6379/0 - APP_ENV: development - FRONTEND_ORIGIN: http://localhost:3000 - ports: - - "8000:8000" - depends_on: - - postgres - - redis - volumes: - - ./backend:/app - - frontend: - build: ./frontend - environment: - VITE_ALLOWED_HOST: prof.open-squared.tech - VITE_DEV_API_PROXY_TARGET: http://backend:8000 - VITE_HMR_HOST: prof.open-squared.tech - VITE_HMR_PROTOCOL: wss - VITE_HMR_CLIENT_PORT: 443 - ports: - - "3000:3000" - depends_on: - - backend - volumes: - - ./frontend:/app - - /app/node_modules - -volumes: - postgres_data: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c003afa..9856dc6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' const API_BASE = '/api' +const AUTO_STOP_SILENCE_MS = 2500 +const SPEECH_START_THRESHOLD = 0.05 async function parseApiResponse(res) { const contentType = res.headers.get('content-type') || '' @@ -84,14 +86,28 @@ export default function App() { const [assessmentAnswer, setAssessmentAnswer] = useState('') const [speaking, setSpeaking] = useState(false) const [isRecording, setIsRecording] = useState(false) + const [isAutoListening, setIsAutoListening] = useState(false) const [isTranscribing, setIsTranscribing] = useState(false) const [voiceStatus, setVoiceStatus] = useState('') const [errorMessage, setErrorMessage] = useState('') - const recognitionRef = useRef(null) + const [voices, setVoices] = useState([]) + const [selectedVoiceURI, setSelectedVoiceURI] = useState('') const mediaRecorderRef = useRef(null) const mediaStreamRef = useRef(null) const recordedChunksRef = useRef([]) const recordingMimeTypeRef = useRef('') + const audioContextRef = useRef(null) + const analyserRef = useRef(null) + const sourceNodeRef = useRef(null) + const animationFrameRef = useRef(null) + const silenceStartedAtRef = useRef(null) + const isRecordingRef = useRef(false) + const isAutoListeningRef = useRef(false) + const isTranscribingRef = useRef(false) + + const availableVoices = useMemo(() => { + return [...voices].sort((a, b) => scoreVoice(b) - scoreVoice(a)) + }, [voices]) const selectedStudent = useMemo( () => students.find((student) => String(student.id) === String(selectedStudentId)), @@ -102,6 +118,18 @@ export default function App() { loadStudents() }, []) + useEffect(() => { + isRecordingRef.current = isRecording + }, [isRecording]) + + useEffect(() => { + isAutoListeningRef.current = isAutoListening + }, [isAutoListening]) + + useEffect(() => { + isTranscribingRef.current = isTranscribing + }, [isTranscribing]) + useEffect(() => { if (selectedStudentId) { loadProgress(selectedStudentId) @@ -109,11 +137,24 @@ export default function App() { }, [selectedStudentId]) useEffect(() => { + if (!('speechSynthesis' in window)) return undefined + + const loadVoices = () => { + const nextVoices = window.speechSynthesis.getVoices() + setVoices(nextVoices) + setSelectedVoiceURI((currentVoiceURI) => { + if (currentVoiceURI) return currentVoiceURI + const preferredVoice = [...nextVoices].sort((a, b) => scoreVoice(b) - scoreVoice(a))[0] + return preferredVoice?.voiceURI || '' + }) + } + + loadVoices() + window.speechSynthesis.onvoiceschanged = loadVoices + return () => { - if (recognitionRef.current) { - recognitionRef.current.abort() - } - stopMediaStream() + window.speechSynthesis.onvoiceschanged = null + deactivateAutoListening(false) } }, []) @@ -168,26 +209,6 @@ export default function App() { setMessages((prev) => [...prev, { role, content, id: crypto.randomUUID() }]) } - async function sendMessage(e) { - e.preventDefault() - if (!selectedStudentId || !input.trim()) return - const text = input.trim() - appendMessage('user', text) - setInput('') - try { - setErrorMessage('') - const data = await apiFetch('/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }), - }) - appendMessage('assistant', data.reply) - speak(data.reply) - } catch (error) { - setErrorMessage(error.message || 'Impossible d’envoyer le message.') - } - } - async function loadProgress(studentId) { try { setErrorMessage('') @@ -238,7 +259,11 @@ export default function App() { if (!('speechSynthesis' in window)) return window.speechSynthesis.cancel() const utterance = new SpeechSynthesisUtterance(text) - utterance.lang = 'fr-FR' + const selectedVoice = voices.find((voice) => voice.voiceURI === selectedVoiceURI) + utterance.lang = selectedVoice?.lang || 'fr-FR' + if (selectedVoice) { + utterance.voice = selectedVoice + } utterance.onstart = () => setSpeaking(true) utterance.onend = () => setSpeaking(false) utterance.onerror = () => setSpeaking(false) @@ -246,6 +271,20 @@ export default function App() { } function stopMediaStream() { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + } + if (sourceNodeRef.current) { + sourceNodeRef.current.disconnect() + sourceNodeRef.current = null + } + if (audioContextRef.current) { + audioContextRef.current.close().catch(() => {}) + audioContextRef.current = null + } + analyserRef.current = null + silenceStartedAtRef.current = null if (mediaStreamRef.current) { mediaStreamRef.current.getTracks().forEach((track) => track.stop()) mediaStreamRef.current = null @@ -255,7 +294,23 @@ export default function App() { recordingMimeTypeRef.current = '' } - async function transcribeRecording(audioBlob, mimeType) { + async function submitUserMessage(text) { + if (!selectedStudentId) { + throw new Error('Choisis un élève avant d’envoyer un message.') + } + appendMessage('user', text) + setInput('') + setErrorMessage('') + const data = await apiFetch('/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }), + }) + appendMessage('assistant', data.reply) + speak(data.reply) + } + + async function transcribeRecording(audioBlob, mimeType, { autoSend = false } = {}) { 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' })) @@ -268,8 +323,21 @@ export default function App() { method: 'POST', body: formData, }) - setInput(data.text || '') - setVoiceStatus(data.text ? 'Texte dicté prêt à être envoyé.' : 'Aucun texte reconnu.') + const transcript = (data.text || '').trim() + setInput(transcript) + + if (!transcript) { + setVoiceStatus('Aucun texte reconnu.') + return + } + + if (autoSend && selectedStudentId) { + setVoiceStatus('Texte reconnu, envoi automatique...') + await submitUserMessage(transcript) + setVoiceStatus('Message vocal envoyé automatiquement.') + } else { + setVoiceStatus('Texte dicté prêt à être envoyé.') + } } catch (error) { setVoiceStatus(error.message || 'Impossible de transcrire cet enregistrement.') } finally { @@ -277,103 +345,157 @@ export default function App() { } } - 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 + function calculateVolume(analyser) { + const data = new Uint8Array(analyser.fftSize) + analyser.getByteTimeDomainData(data) + + let sumSquares = 0 + for (const value of data) { + const normalized = (value - 128) / 128 + sumSquares += normalized * normalized } + return Math.sqrt(sumSquares / data.length) + } + + async function startSegmentRecording() { + if (!mediaStreamRef.current || isRecordingRef.current || isTranscribingRef.current) return + const mimeType = getSupportedRecordingMimeType() - if (!mimeType) { - setVoiceStatus('Aucun format audio compatible n’est disponible dans ce navigateur.') + const recorder = mimeType + ? new MediaRecorder(mediaStreamRef.current, { mimeType }) + : new MediaRecorder(mediaStreamRef.current) + + mediaRecorderRef.current = recorder + recordedChunksRef.current = [] + recordingMimeTypeRef.current = mimeType || recorder.mimeType || 'audio/webm' + silenceStartedAtRef.current = null + + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + recordedChunksRef.current.push(event.data) + } + } + + recorder.onstop = async () => { + const finalMimeType = recordingMimeTypeRef.current || recorder.mimeType || 'audio/webm' + const audioBlob = new Blob(recordedChunksRef.current, { type: finalMimeType }) + recordedChunksRef.current = [] + mediaRecorderRef.current = null + setIsRecording(false) + + if (audioBlob.size > 0) { + await transcribeRecording(audioBlob, finalMimeType, { autoSend: true }) + } else if (isAutoListeningRef.current) { + setVoiceStatus('Micro actif. Parle quand tu veux.') + } + } + + recorder.onerror = () => { + setVoiceStatus('Le navigateur a rencontré une erreur pendant l’enregistrement.') + setIsRecording(false) + } + + recorder.start() + setIsRecording(true) + setVoiceStatus('Je t’écoute...') + } + + function stopSegmentRecording(statusMessage = 'Silence détecté, transcription...') { + const recorder = mediaRecorderRef.current + if (recorder && recorder.state !== 'inactive') { + silenceStartedAtRef.current = null + recorder.stop() + setVoiceStatus(statusMessage) + } + } + + function monitorMicrophone() { + if (!analyserRef.current || !isAutoListeningRef.current) return + + const volume = calculateVolume(analyserRef.current) + const now = Date.now() + + if (volume >= SPEECH_START_THRESHOLD) { + silenceStartedAtRef.current = null + if (!isRecordingRef.current && !isTranscribingRef.current) { + startSegmentRecording() + } + } else if (isRecordingRef.current) { + if (!silenceStartedAtRef.current) { + silenceStartedAtRef.current = now + } else if (now - silenceStartedAtRef.current >= AUTO_STOP_SILENCE_MS) { + stopSegmentRecording() + } + } + + animationFrameRef.current = requestAnimationFrame(monitorMicrophone) + } + + async function activateAutoListening() { + if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { + setVoiceStatus('Le micro automatique n’est pas pris en charge par ce navigateur.') return } try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) - const recorder = new MediaRecorder(stream, { mimeType }) + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }) + const audioContext = new window.AudioContext() + const analyser = audioContext.createAnalyser() + analyser.fftSize = 2048 + analyser.smoothingTimeConstant = 0.85 + const sourceNode = audioContext.createMediaStreamSource(stream) + sourceNode.connect(analyser) + audioContextRef.current = audioContext + analyserRef.current = analyser + sourceNodeRef.current = sourceNode 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.') + setIsAutoListening(true) + setVoiceStatus('Micro actif. Parle quand tu veux, j’enverrai après 2,5 s de silence.') + monitorMicrophone() } 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 deactivateAutoListening(resetStatus = true) { + setIsAutoListening(false) + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { + mediaRecorderRef.current.onstop = null + mediaRecorderRef.current.stop() } - } - - function startBrowserRecognition() { - const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition - if (!Recognition) return false - - const recognition = new Recognition() - recognition.lang = 'fr-FR' - recognition.interimResults = false - recognition.maxAlternatives = 1 - recognition.onresult = (event) => { - const transcript = event.results[0][0].transcript - setInput(transcript) - setVoiceStatus('Texte dicté prêt à être envoyé.') + setIsRecording(false) + stopMediaStream() + if (resetStatus) { + setVoiceStatus('Micro automatique désactivé.') } - recognition.onerror = () => { - setVoiceStatus('La reconnaissance vocale du navigateur a échoué. Essaie l’enregistrement audio.') - } - recognition.onstart = () => { - setVoiceStatus('Écoute en cours...') - } - recognitionRef.current = recognition - recognition.start() - return true } async function startVoiceInput() { - if (isRecording) { - stopRecordedVoiceInput() + if (isAutoListening) { + deactivateAutoListening() return } + await activateAutoListening() + } - const startedNativeRecognition = startBrowserRecognition() - if (!startedNativeRecognition) { - await startRecordedVoiceInput() + async function sendMessage(e) { + e.preventDefault() + if (!selectedStudentId || !input.trim()) return + const text = input.trim() + try { + await submitUserMessage(text) + } catch (error) { + setInput(text) + setErrorMessage(error.message || 'Impossible d’envoyer le message.') } } @@ -463,8 +585,20 @@ export default function App() { onChange={(event) => setInput(event.target.value)} placeholder="Pose une question ou demande une explication..." /> + @@ -475,3 +609,16 @@ export default function App() { ) } + +function scoreVoice(voice) { + let score = 0 + const name = `${voice.name} ${voice.voiceURI}`.toLowerCase() + const lang = (voice.lang || '').toLowerCase() + + if (lang.startsWith('fr')) score += 100 + if (name.includes('google') || name.includes('microsoft')) score += 20 + if (name.includes('natural') || name.includes('premium') || name.includes('enhanced')) score += 15 + if (name.includes('hortense') || name.includes('amelie') || name.includes('thomas')) score += 10 + + return score +}