Add voice
This commit is contained in:
@@ -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() {
|
export default function App() {
|
||||||
const [students, setStudents] = useState([])
|
const [students, setStudents] = useState([])
|
||||||
const [selectedStudentId, setSelectedStudentId] = useState('')
|
const [selectedStudentId, setSelectedStudentId] = useState('')
|
||||||
@@ -45,10 +56,17 @@ export default function App() {
|
|||||||
const [assessment, setAssessment] = useState(null)
|
const [assessment, setAssessment] = useState(null)
|
||||||
const [assessmentAnswer, setAssessmentAnswer] = useState('')
|
const [assessmentAnswer, setAssessmentAnswer] = useState('')
|
||||||
const [speaking, setSpeaking] = useState(false)
|
const [speaking, setSpeaking] = useState(false)
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||||
|
const [voiceStatus, setVoiceStatus] = useState('')
|
||||||
const recognitionRef = useRef(null)
|
const recognitionRef = useRef(null)
|
||||||
|
const mediaRecorderRef = useRef(null)
|
||||||
|
const mediaStreamRef = useRef(null)
|
||||||
|
const recordedChunksRef = useRef([])
|
||||||
|
const recordingMimeTypeRef = useRef('')
|
||||||
|
|
||||||
const selectedStudent = useMemo(
|
const selectedStudent = useMemo(
|
||||||
() => students.find((s) => String(s.id) === String(selectedStudentId)),
|
() => students.find((student) => String(student.id) === String(selectedStudentId)),
|
||||||
[students, selectedStudentId]
|
[students, selectedStudentId]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,6 +80,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [selectedStudentId])
|
}, [selectedStudentId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (recognitionRef.current) {
|
||||||
|
recognitionRef.current.abort()
|
||||||
|
}
|
||||||
|
stopMediaStream()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function loadStudents() {
|
async function loadStudents() {
|
||||||
const res = await fetch(`${API_BASE}/students`)
|
const res = await fetch(`${API_BASE}/students`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -157,12 +184,113 @@ export default function App() {
|
|||||||
window.speechSynthesis.speak(utterance)
|
window.speechSynthesis.speak(utterance)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startVoiceInput() {
|
function stopMediaStream() {
|
||||||
const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
if (mediaStreamRef.current) {
|
||||||
if (!Recognition) {
|
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
|
||||||
alert('La reconnaissance vocale du navigateur n\'est pas disponible ici.')
|
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
|
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()
|
const recognition = new Recognition()
|
||||||
recognition.lang = 'fr-FR'
|
recognition.lang = 'fr-FR'
|
||||||
recognition.interimResults = false
|
recognition.interimResults = false
|
||||||
@@ -170,17 +298,36 @@ export default function App() {
|
|||||||
recognition.onresult = (event) => {
|
recognition.onresult = (event) => {
|
||||||
const transcript = event.results[0][0].transcript
|
const transcript = event.results[0][0].transcript
|
||||||
setInput(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
|
recognitionRef.current = recognition
|
||||||
recognition.start()
|
recognition.start()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startVoiceInput() {
|
||||||
|
if (isRecording) {
|
||||||
|
stopRecordedVoiceInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedNativeRecognition = startBrowserRecognition()
|
||||||
|
if (!startedNativeRecognition) {
|
||||||
|
await startRecordedVoiceInput()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
<aside className="sidebar card">
|
<aside className="sidebar card">
|
||||||
<h2>Élève</h2>
|
<h2>Élève</h2>
|
||||||
<select value={selectedStudentId} onChange={(e) => setSelectedStudentId(e.target.value)}>
|
<select value={selectedStudentId} onChange={(event) => setSelectedStudentId(event.target.value)}>
|
||||||
<option value="">Choisir un élève</option>
|
<option value="">Choisir un élève</option>
|
||||||
{students.map((student) => (
|
{students.map((student) => (
|
||||||
<option key={student.id} value={student.id}>
|
<option key={student.id} value={student.id}>
|
||||||
@@ -193,16 +340,16 @@ export default function App() {
|
|||||||
<input
|
<input
|
||||||
placeholder="Prénom"
|
placeholder="Prénom"
|
||||||
value={form.first_name}
|
value={form.first_name}
|
||||||
onChange={(e) => setForm({ ...form, first_name: e.target.value })}
|
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="8"
|
min="8"
|
||||||
max="12"
|
max="12"
|
||||||
value={form.age}
|
value={form.age}
|
||||||
onChange={(e) => setForm({ ...form, age: e.target.value })}
|
onChange={(event) => setForm({ ...form, age: event.target.value })}
|
||||||
/>
|
/>
|
||||||
<select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
<select value={form.grade} onChange={(event) => setForm({ ...form, grade: event.target.value })}>
|
||||||
<option>CE2</option>
|
<option>CE2</option>
|
||||||
<option>CM1</option>
|
<option>CM1</option>
|
||||||
<option>CM2</option>
|
<option>CM2</option>
|
||||||
@@ -249,7 +396,7 @@ export default function App() {
|
|||||||
<p>{assessment.question}</p>
|
<p>{assessment.question}</p>
|
||||||
<input
|
<input
|
||||||
value={assessmentAnswer}
|
value={assessmentAnswer}
|
||||||
onChange={(e) => setAssessmentAnswer(e.target.value)}
|
onChange={(event) => setAssessmentAnswer(event.target.value)}
|
||||||
placeholder="Ta réponse"
|
placeholder="Ta réponse"
|
||||||
/>
|
/>
|
||||||
<button type="submit">Valider</button>
|
<button type="submit">Valider</button>
|
||||||
@@ -259,13 +406,17 @@ export default function App() {
|
|||||||
<form onSubmit={sendMessage} className="composer">
|
<form onSubmit={sendMessage} className="composer">
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
placeholder="Pose une question ou demande une explication..."
|
placeholder="Pose une question ou demande une explication..."
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={startVoiceInput}>🎤 Dicter</button>
|
<button type="button" onClick={startVoiceInput} disabled={isTranscribing}>
|
||||||
|
{isRecording ? 'Arrêter' : isTranscribing ? 'Transcription...' : 'Dicter'}
|
||||||
|
</button>
|
||||||
<button type="submit">Envoyer</button>
|
<button type="submit">Envoyer</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{voiceStatus && <p className="muted voice-status">{voiceStatus}</p>}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user