Saisie vocale dans le navigateur : les pièges à connaître
Ajoutez des réponses vocales à une application web avec MediaRecorder et Web Speech API, en évitant les courses critiques et bugs de synchronisation.
Long Nguyen
Développeur fullstack · Ingénieur IA · Chercheur
Pourquoi la voix est le point que les tutoriels esquivent
Un entretien auquel on répond en tapant n’est pas vraiment un entraînement à l’entretien. Toute l’idée de cette application est de répondre à voix haute. Cette partie ajoute donc la saisie vocale dans le navigateur avec la reconnaissance vocale : la fonctionnalité qui rend l’expérience réaliste, et celle que la plupart des tutoriels évitent discrètement. Dans la partie 7, le pipeline générait les questions ; maintenant, le candidat répond à l’oral.
Le navigateur nous fournit deux API pour cela : MediaRecorder pour capturer l’audio réel, et la Web Speech API (SpeechRecognition) pour le transcrire en direct. Chacune est simple à utiliser séparément. Les faire fonctionner ensemble, de manière fiable et sur de vrais appareils, est une tout autre affaire. Cette partie s’intéresse donc surtout à ces difficultés, car elles font la différence entre « ça fonctionne dans la démo » et « ça fonctionne pour de vrais utilisateurs ».
Capturer l’audio et la voix simultanément
L’objectif est de conserver à la fois un véritable enregistrement audio (pour une transcription ultérieure plus précise) et une transcription en direct (pour un retour immédiat à l’écran). Nous activons d’abord le microphone, puis la reconnaissance vocale :
async function startAnswerCapture() {
let micOk = false;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
chunks = [];
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.start();
micOk = true;
} catch (err) {
mediaRecorder = null; // no mic — we'll fall back to transcript only
}
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
recognition = new SR();
recognition.lang = 'en-US';
recognition.continuous = true;
recognition.interimResults = true;
// ... handlers below
recognition.start();
}
return micOk;
}
Cette séquence n’est pas anodine. Obtenez l’accès au microphone avant de démarrer la reconnaissance. Si la reconnaissance vocale est déjà active lorsque vous appelez getUserMedia, Chrome interrompt la session de reconnaissance, sans afficher d’erreur. Vous vous retrouvez alors avec une transcription vide. Demander d’abord l’autorisation d’utiliser le microphone évite entièrement ce conflit. C’est le premier piège, et il reste invisible jusqu’à ce qu’il se manifeste en production.
Piège : les résultats intermédiaires sont indispensables
On peut être tenté de ne conserver que les résultats « finaux » et d’ignorer les résultats intermédiaires : le résultat final semble être la version propre et terminée. C’est un piège. Chrome ne finalise un résultat qu’après avoir détecté un intervalle de silence. Si un candidat parle jusqu’au moment où il clique sur Envoyer, sans marquer de pause à la fin, ce dernier segment ne sera jamais finalisé et la réponse arrivera entièrement vide.
recognition.onresult = (e) => {
interimTranscript = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
finalTranscript += e.results[i][0].transcript + ' ';
} else {
interimTranscript += e.results[i][0].transcript + ' '; // keep these!
}
}
};
Nous suivons donc le texte intermédiaire séparément et ne le supprimons jamais : il est affiché en direct, puis intégré à la transcription finale lorsque la session se termine. La correction est minime ; le découvrir signifie généralement avoir déjà perdu plusieurs réponses réelles à cause de soumissions vides.
Piège : la reconnaissance s’arrête toute seule
Même en mode continuous, SpeechRecognition met fin à sa session après une période de silence. Un candidat qui s’arrête pour réfléchir au milieu de sa réponse peut donc voir la reconnaissance s’interrompre discrètement. Nous la redémarrons donc, mais uniquement tant que la réponse est toujours active, et nous récupérons les mots intermédiaires de la session qui vient de se terminer, puisqu’ils n’auront jamais l’occasion d’être finalisés :
recognition.onend = () => {
if (interimTranscript.trim()) {
finalTranscript += interimTranscript.trim() + ' ';
interimTranscript = '';
}
if (recognitionActive) {
try { recognition.start(); } catch (err) { /* already restarting */ }
} else if (onRecognitionEnd) {
onRecognitionEnd();
}
};
Le indicateur recognitionActive sert de garde-fou : tant que la réponse est en cours, nous redémarrons automatiquement la reconnaissance ; une fois l’arrêt demandé volontairement, nous la laissons se terminer et prévenons le code en attente.
Piège : l’arrêt déclenche une course critique
Le cas le plus délicat survient à la fin. Lorsque vous appelez recognition.stop(), les derniers résultats n’arrivent pas de manière synchrone : ils sont déclenchés après l’arrêt, un instant plus tard. Si vous lisez immédiatement la transcription, vous perdez les derniers mots de chaque réponse. L’arrêt doit donc attendre la fin effective de la session, avec un délai maximal afin qu’un navigateur qui ne déclencherait jamais onend ne puisse pas bloquer l’application :
function stopRecognitionAndWait() {
return new Promise((resolve) => {
recognitionActive = false;
const rec = recognition;
if (!rec) { resolve(); return; }
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(fallback);
rec.onresult = rec.onend = rec.onerror = null; // stop late events leaking
recognition = null;
resolve();
};
const fallback = setTimeout(finish, 2000); // never hang forever
onRecognitionEnd = finish;
try { rec.stop(); } catch (err) { finish(); }
});
}
Deux mesures défensives rendent cette logique sûre : le délai de secours de 2 secondes garantit que l’application poursuivra toujours son exécution, même si le navigateur se comporte mal, et le détachement des gestionnaires empêche un événement tardif d’une question de se propager à la suivante. Ce n’est qu’une fois cette étape terminée que nous arrêtons l’enregistreur et lisons la transcription finale. La difficulté vient du timing, pas de l’API.
Envoyer la réponse à Django
Une fois le fichier audio et la transcription disponibles, nous les envoyons ensemble. L’audio est ignoré s’il est absent ou dépasse la limite de 5 Mo du backend ; la transcription suffit malgré tout à conserver la réponse :
const formData = new FormData();
if (capturedBlob && capturedBlob.size > 0 && capturedBlob.size <= 5_000_000) {
formData.append('audio', capturedBlob, 'answer.webm');
}
formData.append('transcript', (finalTranscript + ' ' + interimTranscript).trim());
formData.append('question_id', q.id);
await interviewAPI.submitAnswer(interviewId, formData);
Sur le serveur, la vue valide l’audio à partir de son contenu réel, et non de son nom de fichier, qui est toujours answer.webm quel que soit le codec utilisé. Elle enregistre ensuite l’enregistrement et la transcription sur la question :
class SubmitAnswerAPIView(APIView):
parser_classes = [MultiPartParser]
def post(self, request, *args, **kwargs):
audio = request.FILES.get('audio')
transcript = request.data.get('transcript')
if audio and audio.size > 5_000_000:
return Response({'err': 'Max file size is 5MB'}, status=400)
if audio and not is_valid_audio_file(audio):
return Response({'err': 'Audio format not supported!'}, status=400)
question = InterviewQuestion.objects.get(
id=request.data.get('question_id'), interview=interview,
)
if question.status == 2: # already answered — ignore duplicates
return Response({'msg': 'success'}, status=200)
question.record = audio
question.user_answer = transcript
question.status = 2 # completed
question.save(update_fields=['record', 'user_answer', 'status', 'updated_at'])
return Response(status=201)
Remarquez la vérification question.status == 2 : le minuteur peut envoyer automatiquement la réponse au même moment que l’utilisateur clique, l’endpoint doit donc être idempotent. Une seconde soumission pour une question déjà répondue est acceptée discrètement au lieu d’être traitée deux fois. Conserver l’audio brut est également volontaire : la transcription du navigateur est pratique, mais imparfaite. Garder l’enregistrement permet donc de le retranscrire ultérieurement avec un service plus précis.
Ce que cette fonctionnalité permet
Avec la voix, l’application fait enfin ce pour quoi elle a été conçue : elle lit chaque question à voix haute, écoute la réponse du candidat, affiche une transcription en direct et conserve l’enregistrement ainsi que le texte. Il s’agit d’un véritable entraînement à l’entretien, pas d’un simple exercice de saisie.
Presque tout le travail ne concernait pas le « parcours nominal », mais le timing : obtenir l’accès au microphone dans le bon ordre, conserver les résultats intermédiaires, redémarrer après un silence et attendre la fin réelle de l’arrêt. C’est là que réside la véritable difficulté de la voix dans le navigateur, et c’est pourquoi tant de démos fonctionnent une fois avant d’échouer avec de vrais utilisateurs. Si vous préférez partir d’une version finalisée et testée en production, elle est incluse dans le code source complet du kit de démarrage. Sinon, la partie 9 vous attend : l’IA bouclera le processus en évaluant chaque réponse et en transformant toute la session en rapport final d’entretien.
FAQ
Questions fréquentes
Pourquoi obtenir l’accès au microphone avant de démarrer la reconnaissance vocale ?
Si SpeechRecognition est déjà actif lorsque vous appelez getUserMedia, Chrome interrompt discrètement la session de reconnaissance, ce qui laisse une transcription vide sans afficher d’erreur. Demander d’abord l’autorisation d’utiliser le microphone évite entièrement ce conflit.
Pourquoi ma transcription vocale est-elle parfois vide ?
La cause la plus fréquente est l’ignorance des résultats intermédiaires. Chrome ne finalise un résultat qu’après un intervalle de silence. Si une personne parle jusqu’au moment de la soumission, sans pause à la fin, les derniers mots ne sont jamais finalisés. Suivre et conserver les résultats intermédiaires corrige le problème.
Pourquoi SpeechRecognition s’arrête-t-il tout seul, même en mode continu ?
Il met fin à sa session après une période de silence, ce qui arrive naturellement lorsqu’une personne s’arrête pour réfléchir. Pour gérer ce cas, redémarrez la reconnaissance dans le gestionnaire onend tant que la réponse est active, et récupérez les mots intermédiaires de la session terminée.
Pourquoi enregistrer l’audio si le navigateur le transcrit déjà ?
La transcription du navigateur est pratique, mais imparfaite et variable selon l’appareil. Conserver l’enregistrement audio brut permet de le retranscrire ultérieurement avec un service plus précis, sans dépendre du résultat produit par le navigateur sur le moment.
Pourquoi vérifier le contenu du fichier audio plutôt que son extension ?
Le nom du fichier envoyé est toujours answer.webm, quel que soit le codec réel. L’extension ne fournit donc aucune information fiable. La vérification des magic bytes du fichier est le seul moyen de confirmer qu’il s’agit bien d’un fichier audio avant de l’enregistrer.