Présentation
Elyne lit est une application de coaching de lecture à voix haute pour les enfants. L'enfant choisit une histoire et un niveau de lecture — syllabe par syllabe, mot par mot, phrase par phrase, ou texte entier avec bilan final. Le système transcrit, compare au texte attendu, évalue la prononciation et renvoie un retour vocal encourageant : « ✓ Bravo ! » ou une explication pédagogique générée par LLM avec un second essai.
Architecturalement, c'est un système de streaming audio bidirectionnel via WebSocket : le frontend envoie des chunks PCM Float32 (125ms) depuis l'AudioWorklet du navigateur — uniquement quand le backend attend de l'audio (micro « gated ») — et le backend Python orchestre la chaîne de traitement, répondant par des événements JSON structurés.
Contrainte principale : l'app est conçue pour Elyne, 6 ans. Latence perçue < 1s, zéro friction, zéro inscription. Un bouton, une histoire, on lit.
| Service | Port | URL |
|---|---|---|
| Backend FastAPI | 8010 | http://localhost:8010 |
| Frontend Next.js | 3010 | http://localhost:3010 |
| WebSocket | 8010 | ws://localhost:8010/ws/read/{story_id} |
Changements Phase 2a
Phase 2a corrige la session WebSocket qui se déconnectait en milieu de lecture (symptôme : "Je lis !" réapparaissait après ~"Chaperon Rouge").
- ws.pyasyncio.to_thread — Whisper tourne dans un thread pool ; l'event loop reste libre pour recevoir les chunks audio pendant la transcription.
- ws.pySILENCE_TIMEOUT = 3s —
asyncio.wait_for(receive_bytes, timeout=3): flush forcé si aucun chunk depuis 3 secondes. - ws.pyFlush final — appel supplémentaire à
process_buffer()après la boucle principale pour les derniers mots. - ws.pyAnti-overlap — saut de >5 mots accepté uniquement si distance Levenshtein = 0, évitant les faux positifs.
- vad.pyThreshold 0.005 → 0.002 configurable via env
VAD_THRESHOLD— voix enfant (~RMS 0.003) n'est plus filtrée. - asr.pyvad_filter=False — filtre VAD interne de Whisper désactivé pour éviter double filtrage des voix douces.
- pronunciation.pytry/except Claude API — fallback si l'API Anthropic est indisponible, sans crasher le serveur.
Résultat test : python test_pipeline.py → 16/16 mots validés, "HISTOIRE TERMINÉE" ✅ — régression nulle.
Problèmes terrain & solutions
Phase 3 part d'un constat utilisateur : « l'app ne fonctionne pas vraiment ». Chaque problème ci-dessous a été identifié soit par lecture du code, soit par analyse des sessions de test enregistrées (log JSONL + WAV de chaque tentative — voir Debug terrain), puis corrigé et validé par les tests.
Synchronisation temps réel
| Symptôme | Cause racine | Solution |
|---|---|---|
| Le mot affiché est évalué sur l'audio du mot précédent | Backpressure : le frontend envoyait des chunks en continu pendant que Whisper/TTS travaillaient ; les chunks s'accumulaient dans le buffer WS et le mot suivant lisait du vieil audio | Micro « gated » (envoi PCM uniquement entre next_word et le résultat) + _drain_stale_audio() côté serveur avant chaque annonce |
| Signal pollué, transcriptions erratiques | worklet.connect(ctx.destination) rejouait le micro dans les enceintes (écho) | GainNode à 0 entre worklet et destination — le worklet reste actif sans repasse audio |
| Fin de mot détectée avec ~500ms de retard | Chunks audio de 500ms — granularité de détection trop grossière | Chunks réduits à 125ms ; détection de silence basée sur la durée (indépendante de la taille des chunks) |
Capture audio
| Symptôme | Cause racine | Solution |
|---|---|---|
| « était » lu correctement → transcript vide | Le VAD ne démarrait l'enregistrement qu'au premier chunk au-dessus du seuil : l'attaque du mot (souvent plus faible) était coupée | Pré-roll 250ms — fenêtre glissante d'audio pré-parole, prépendue au buffer à la détection |
| Sur du quasi-silence, Whisper invente une phrase entière (« Je vous invite à vous faire une vidéo » pour « à ») | Hallucination connue de Whisper sur les entrées vides ou quasi vides | Garde de parole minimale : moins de 120ms de frames au-dessus du seuil VAD → pas de transcription, on avance sans pénalité |
Évaluation — ne jamais accuser l'enfant à tort
| Symptôme | Cause racine | Solution |
|---|---|---|
| « roi » lu juste → « tu as dit oh » | Évaluation du premier token alors que Whisper ajoute des interjections (« Oh, roi ! ») | _closest_token() — token de la transcription le plus proche du mot attendu (Levenshtein) |
| « était » → corrigé sur « Et » | Whisper fragmente un mot en plusieurs tokens (« Et t'es ») | La concaténation des tokens est aussi candidate ; à distance égale, le candidat le plus long gagne |
| « porter », « roi », « au », « lui » lus justes mais corrigés | Whisper choisit une orthographe arbitraire entre homophones (porté/portez, rois, Oh/eau, Louis) | Terminaisons muettes neutralisées (-s/-x/-z, -er≡é) + table d'homophones (/o/, lui/Louis) |
| « l'appelait » → corrigé alors que le mot y est | Élisions ajoutées par Whisper | Mot attendu (≥3 lettres) contenu dans la transcription recollée → validé |
| Corrections incompréhensibles sur « il », « le », « à » | L'ASR est statistiquement peu fiable sur les clips de ~300ms | Mots ≤2 lettres : jamais d'accusation « mauvais mot » — on laisse passer |
| Mot complètement faux silencieusement accepté | Distance Levenshtein trop grande classée « pas le même mot, pas une erreur » | Feedback explicite « J'ai entendu X, le mot était Y » (template, sans appel LLM) + second essai |
| Les corrections n'apparaissaient jamais à l'écran | Le next_word suivant partait ~50ms après la correction et effaçait la bulle | Pause de 3,5s après chaque correction — la bulle reste affichée, le TTS a le temps d'être entendu |
Choix de modèle ASR — leçon du test réel
Le bench par clip donnait l'avantage à Whisper small (fiable sur mots courts et syllabes, ~1,1s) sur base (308ms, fragmente les mots courts). En session réelle, small s'est avéré dégradé : avec un cycle de ~2s par mot, le lecteur « double » l'application — « Il était » lu d'un trait part dans la fenêtre de « Il » et toute la suite se désynchronise. La vitesse du cycle prime sur la précision par clip : base est conservé, les tolérances d'évaluation compensent ses faiblesses.
Stack complète
| Couche | Technologie | Rôle | Phase |
|---|---|---|---|
| Frontend | Next.js 15 + TypeScript | UI enfant, AudioWorklet, WS client | ✅ Phase 1 |
| Backend | FastAPI + Uvicorn | WS server, orchestration pipeline | ✅ Phase 1 |
| Audio capture | AudioWorklet (navigateur) | PCM Float32 16kHz, chunks 125ms, micro gated | ✅ Phase 3 |
| VAD | RMS threshold + pré-roll 250ms | Filtre silence / parole, garde anti-hallucination | ✅ Phase 3 |
| ASR | Provider swappable (env ASR_PROVIDER) | Faster-Whisper base local (308ms M2) / Azure Speech (cloud) | ✅ Phase 3 |
| Syllabation | Règles orthographiques FR | Découpage CP : digrammes insécables, clusters +l/r | ✅ Phase 3 |
| Alignement | Levenshtein sliding window + closest token | Aligne transcript sur story_words, tolère fragments/interjections | ✅ Phase 3 |
| Prononciation | Règles phonétiques + homophones + Levenshtein | Détecte erreurs phonèmes, neutralise les pièges ASR | ✅ Phase 3 |
| LLM | Provider swappable (env LLM_PROVIDER) | Mistral Small (free tier) / Claude Haiku 4.5 — explication pédagogique | ✅ Phase 3 |
| TTS | edge-tts fr-FR-DeniseNeural | Synthèse correction audio + résumé de bilan | ✅ Phase 2a |
| Debug | SessionRecorder (?debug=1) | log.jsonl + WAV par tentative dans data/sessions/ | ✅ Phase 3 |
| VAD IA | Silero VAD v5 | Remplacement RMS par modèle IA | Phase 4 |
| Phonème IA | wav2vec2-large-xlsr-53-fr | Évaluation phonème réelle | Phase 4 |
Architecture système
Architecture complète — pipeline audio bidirectionnel WebSocket
Flux de traitement d'un chunk audio
Pipeline complet — chunk PCM → feedback audio
Seuil adaptatif de buffer
# En fin d'histoire, on réduit le seuil pour ne pas bloquer
remaining = len(story_words) - word_idx
threshold = min(BUFFER_SIZE, max(8000, remaining * 3200))
# BUFFER_SIZE = 24000 (1.5s) min = 8000 (0.5s)
# Derniers 3 mots → threshold = 9600 Dernier mot → 8000
Diagramme de séquence — Session complète
Échanges complets Client ↔ FastAPI pendant une session de lecture
Protocole WebSocket
Endpoint : ws://localhost:8010/ws/read/{story_id}?mode=<mode>&debug=<0|1>
| Mode | Comportement |
|---|---|
syllable | Syllabe par syllabe (expérimental — l'ASR complète les syllabes isolées) |
word | Mot par mot, feedback immédiat — défaut |
sentence | Phrase par phrase, alignement mot à mot, une correction max par phrase |
batch | Lecture libre du texte entier, bilan complet à la fin |
stream | Streaming continu Phase 2a (hors UI, conservé pour expérimentation) |
1. Initialisation (JSON)
{ "text": "Il était une fois une petite fille que tout le monde appelait..." }
2. Chunks audio (binaire)
PCM Float32 Little Endian, 16kHz mono, 2000 samples / chunk (125ms). En modes unité, le client n'envoie qu'entre next_word et le résultat (micro gated) :
// audio-processor.js
this.port.postMessage(buffer, [buffer.buffer]) // transferrable — zéro copie
// ws.ts — gate
if (recordingRef.current && ws.readyState === WebSocket.OPEN) ws.send(e.data.pcm)
3. Événements serveur → client (JSON)
// Unité à lire (syllabe, mot ou phrase selon le mode)
{ "type": "next_word", "payload": { "word_idx": 5, "word": "galette", "total": 47 } }
// Transcription de la tentative
{ "type": "transcript", "payload": { "text": "galette" } }
// Lecture juste — toast « ✓ Bravo ! » côté client
{ "type": "praise", "payload": { "word": "galette" } }
// Mot validé (progression)
{ "type": "progress", "payload": { "word_idx": 6, "total": 47 } }
// Erreur — bulle affichée 3,5s puis second essai
{
"type": "correction",
"payload": {
"word": "roi", "spoken": "rou",
"phoneme_expected": "/wa/", "phoneme_got": "rou",
"explanation": "Le son 'oi' se prononce /wa/ — comme dans 'moi' !",
"audio_b64": "..."
}
}
// Mode batch uniquement
{ "type": "analyzing", "payload": {} }
{
"type": "report",
"payload": {
"read": 42, "total": 47,
"errors": [{ "word_idx": 12, "word": "galette", "spoken": "calette", "explanation": "..." }],
"missed": [{ "word_idx": 30, "word": "miroir" }],
"read_idx": [0, 1, ...], "error_idx": [12],
"summary": "Bravo, tu as lu 42 mots sur 47 !",
"audio_b64": "..."
}
}
// Histoire terminée
{ "type": "complete", "payload": {} }
Budget de latence
Objectif : feedback perçu < 1,5 seconde après le mot lu. Mesures réelles via scripts/bench_asr.py (échantillons TTS syllabes / mots / phrases, machine de dev M2 Max).
| Composant | Mesuré (M2 Max) | Note |
|---|---|---|
| VAD (RMS) + fin de mot | <1ms + 500ms de silence | Chunks 125ms — détection au quart de seconde près |
| Whisper base int8 | 308ms médian, p95 972ms | Précision bench 8/9 — l'échec restant : syllabe isolée |
| Whisper small int8 | 1 106ms médian | Testé puis rejeté — voir Fixes Phase 3 |
| Alignement / évaluation | <2ms | Levenshtein pur Python |
| LLM (explication, si erreur) | ~400–900ms | Mistral Small ou Claude Haiku, 80 tokens max |
| edge-tts (synthèse, si erreur) | ~500–1 500ms | Inclus dans la pause de 3,5s d'affichage de la correction |
| Lecture juste (cas nominal) | ~800ms parole finie → ✓ Bravo | 500ms silence + 308ms Whisper |
VADService — Détection d'activité vocale
# backend/app/services/vad.py
import os, numpy as np
_RMS_THRESHOLD = float(os.getenv("VAD_THRESHOLD", "0.002"))
class VADService:
def __init__(self, threshold: float = _RMS_THRESHOLD):
self._threshold = threshold
def is_speech(self, audio: np.ndarray) -> bool:
rms = float(np.sqrt(np.mean(audio ** 2)))
return rms >= self._threshold
| VAD_THRESHOLD | Voix adulte | Voix enfant douce | Silence |
|---|---|---|---|
| 0.005 (ancien) | ✅ détecté | ⚠️ souvent ignoré | ✅ ignoré |
| 0.002 (actuel) | ✅ détecté | ✅ détecté | ✅ ignoré |
| 0.001 | ✅ détecté | ✅ détecté | ⚠️ bruit ambiant inclus |
Compléments Phase 3 autour du VAD
- Pré-roll 250ms — l'attaque d'un mot est souvent sous le seuil RMS ; une fenêtre glissante d'audio pré-parole est conservée et prépendue au buffer dès la détection, sinon Whisper reçoit un mot tronqué.
- Garde de parole minimale — un enregistrement contenant moins de 120ms de frames au-dessus du seuil n'est pas transcrit : Whisper hallucine des phrases complètes sur du quasi-silence.
- Fin d'unité par durée — 500ms de silence cumulé (mot/syllabe), 1s (phrase), 4s (texte entier en mode batch), indépendant de la taille des chunks.
ASR — interface provider swappable
La reconnaissance vocale est derrière une interface commune, sélectionnée par l'env ASR_PROVIDER. Le local couvre le développement et l'usage maison ; le cloud prépare le déploiement (un VPS CPU est trop lent pour Whisper).
# backend/app/services/asr.py
class FasterWhisperProvider: # défaut — base int8, 308ms médian sur M2 Max
def load(self) -> None: ...
def transcribe(self, audio, language="fr", initial_prompt="") -> str: ...
class AzureSpeechProvider: # ASR_PROVIDER=azure + AZURE_SPEECH_KEY
# push stream PCM16 + PhraseListGrammar (hint du texte attendu)
...
def get_provider():
name = os.getenv("ASR_PROVIDER", "whisper").lower()
return AzureSpeechProvider() if name == "azure" else FasterWhisperProvider()
initial_prompt — le texte attendu (unité courante, ou les 30 premiers mots en mode batch) est passé à Whisper, réduisant les hallucinations. scripts/bench_asr.py compare latence et précision des providers sur des échantillons TTS.
_align_transcription — Levenshtein
def _align_transcription(transcribed_words, story_words, start_idx):
matches = []
search_start = start_idx
for spoken in transcribed_words:
n_spoken = normalize(spoken)
best_idx, best_dist = None, 999
window = story_words[search_start:search_start + 15]
for i, expected in enumerate(window):
d = _levenshtein_fast(n_spoken, normalize(_clean_word(expected)))
if d < best_dist:
best_dist, best_idx = d, search_start + i
skip = best_idx - search_start
threshold = max(1, len(normalize(_clean_word(story_words[best_idx]))) // 3)
if best_dist <= threshold and not (skip > 5 and best_dist > 0):
matches.append((best_idx, spoken, story_words[best_idx]))
search_start = best_idx + 1
return matches
| Paramètre | Valeur | Raison |
|---|---|---|
| Fenêtre glissante | 15 mots | Lecteur rapide peut sauter des mots |
| Threshold Levenshtein | max(1, len//3) | Tolérant sur les mots longs |
| Max skip sans match exact | 5 mots | Évite les faux positifs de l'overlap audio 0.4s |
| Overlap buffer conservé | 6400 samples (0.4s) | Évite de perdre les mots en bord de chunk |
PronunciationService — Phonèmes français
FRENCH_PHONEMES = {
"oi": "/wa/", "ou": "/u/", "eau": "/o/",
"an": "/ɑ̃/", "en": "/ɑ̃/", "in": "/ɛ̃/",
"eu": "/ø/", "au": "/o/", "ai": "/ɛ/",
"ei": "/ɛ/", "gn": "/ɲ/", "ch": "/ʃ/",
"ph": "/f/",
}
# LLM_PROVIDER=mistral (défaut actuel, free tier) ou anthropic
prompt = (
f"Un enfant de 6 ans a dit '{spoken}' au lieu de '{expected}'."
f" Le son '{phoneme}' se prononce {ipa}."
f" Explique en une seule phrase courte et encourageante, avec un exemple."
)
# Sortie : "Dis 'pot' comme 'papa' mais en fermant bien tes lèvres après le 'p' !"
Tolérances avant toute correction
L'ordre d'évaluation est conçu pour ne jamais corriger l'enfant sur un artefact de l'ASR :
- Forme canonique égale — terminaisons muettes neutralisées (-s/-x/-z, -er≡é) + table d'homophones (oh/au/eau → /o/, Louis → lui) → juste.
- Mot contenu — le mot attendu (≥3 lettres) apparaît dans la transcription recollée (élisions « l'appelait », hésitations « Oh, roi ! ») → juste.
- Distance faible (≤ max(2, len/2)) → erreur de phonème, explication LLM + second essai.
- Distance grande → mauvais mot, feedback template « J'ai entendu X, le mot était Y » + second essai — sauf mots ≤2 lettres (ASR trop peu fiable, on laisse passer).
Phase 4 : remplacement par wav2vec2-large-xlsr-53-french pour une évaluation phonème réelle au niveau du signal audio.
TTSService — edge-tts
API gratuite Microsoft utilisant les voix neuronales Azure. Voix : fr-FR-DeniseNeural. L'audio MP3 est encodé en base64 et envoyé via WebSocket.
import edge_tts, base64, tempfile
async def speak(text: str) -> str:
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
path = f.name
await edge_tts.Communicate(text, "fr-FR-DeniseNeural").save(path)
audio_bytes = open(path, "rb").read()
os.unlink(path)
return base64.b64encode(audio_bytes).decode()
Format des histoires
{
"id": "le-chat-de-lila",
"title": "Le chat de Lila",
"level": 1,
"theme": "nature",
"text": "Lila a un chat. Le chat est gris. Il dort sur le lit de Lila. ..."
}
| Histoire | Niveau | Mots | Calibrage |
|---|---|---|---|
| Le chat de Lila | 1 | 47 | Sons simples, phrases courtes |
| La moto de papa | 1 | 49 | Onomatopées, répétitions |
| La tortue perdue | 2 | 63 | Dialogue simple |
| Le voyage sur la lune | 3 | 100 | Vocabulaire plus riche |
| Le Petit Chaperon Rouge | 3 | 110 | Conte classique (« chevillette », « bobinette ») |
| Champ | Type | Description |
|---|---|---|
| id | string | Identifiant URL-safe, correspond au nom de fichier |
| level | 1–3 | 1=CP, 2=CE1, 3=CE2 |
| theme | string | conte, aventure, science, nature |
| text | string | Texte brut — tokenisé par .split() |
Phonèmes ciblés — français
| Graphème | IPA | Exemple | Erreur type |
|---|---|---|---|
| oi | /wa/ | roi, bois, loi | "rou" au lieu de /wa/ |
| ou | /u/ | loup, bout, genou | "o" ou "ô" |
| eau / au | /o/ | beau, chapeau, faux | "eau" lu comme /eo/ |
| an / en | /ɑ̃/ | enfant, grand, vent | nasale non réalisée |
| in | /ɛ̃/ | lapin, fin, chemin | "in" prononcé /in/ |
| ch | /ʃ/ | chaperon, chaud | "ch" prononcé /k/ |
| gn | /ɲ/ | montagne, peigne | "gn" prononcé séparé |
Whisper vs Cloud ASR — comparaison
| Dimension | Whisper local | Deepgram / Google STT |
|---|---|---|
| Où le calcul se fait | Sur ta machine (CPU/GPU) | Sur les serveurs du fournisseur |
| Coût | Gratuit — modèle téléchargé une fois | Payant ~$0.004–0.02/min |
| Internet requis | Non — fonctionne hors ligne | Oui — latence réseau ajoutée |
| Streaming natif | Non — conçu pour fichier complet | Oui — WebSocket temps réel |
| Vitesse CPU | 280–800ms pour 1.5s audio | ~200ms réseau + transcription |
| Vie privée | 100% local — aucune donnée envoyée | Audio envoyé au cloud |
Décision actuelle : Whisper base local — 308ms médian mesuré sur M2 Max, gratuit, hors-ligne. Le provider Azure Speech est implémenté derrière la même interface pour le déploiement cloud (le free tier ~5h/mois couvre un usage familial).
Quatre niveaux, une seule boucle
L'enfant choisit la granularité sur la page de l'histoire. Les modes syllabe / mot / phrase partagent la même boucle _unit_session : le texte est découpé en Unit (texte affiché, indices de mots couverts, type), seuls les paramètres d'enregistrement changent.
| Mode | Découpage | Fin d'unité | Feedback |
|---|---|---|---|
| Syllabes (essai) | syllabify.py — règles orthographiques CP (digrammes ch/ph/th/gn/qu/gu insécables, clusters consonne+l/r, doubles consonnes) | 500ms de silence | Immédiat ; un token commençant par la syllabe attendue est accepté (l'ASR complète « cha » → « chaque ») |
| Mots | Tokens du texte, ponctuation seule ignorée | 500ms de silence | Immédiat : ✓ Bravo, ou correction 3,5s + second essai |
| Phrases | Split sur .!? | 1s de silence (pauses intra-phrase tolérées) | Alignement mot à mot, une correction max par phrase |
| Texte entier | Aucun — lecture libre | 4s de silence | Bilan différé (voir ci-dessous) |
Boucle commune des modes unité — on avance toujours, jamais plus de deux essais
Mode texte entier — bilan différé
Lecture sans interruption : zéro turn-taking, donc zéro risque de désynchronisation, et Whisper est nettement plus précis avec un long contexte qu'avec des clips d'un mot. Après 4s de silence, la transcription complète est alignée sur le texte (fenêtre Levenshtein) et le bilan est envoyé : score lu/total, texte colorié (lu / à retravailler / non entendu), erreurs détaillées (les 3 premières expliquées par le LLM, les autres en template — flag explain sur assess()), mots sautés, résumé encourageant lu en TTS.
Enregistrement de session
Une case « Session test » sur l'écran de choix du niveau ajoute debug=1 à l'URL WebSocket. Le backend écrit alors dans data/sessions/<horodatage>_<histoire>_<mode>/ :
log.jsonl— chaque étape du pipeline horodatée : unité annoncée, durée et RMS de l'audio capté, transcription, évaluation (attendu / entendu / verdict), correction envoyée ;NNN_<mot>.wav— l'audio exact reçu par l'ASR pour chaque tentative.
{"t": "03:42:38.012", "event": "transcript", "text": "Il."}
{"t": "03:42:38.012", "event": "assess", "expected": "Il", "spoken": "Il", "has_error": false}
{"t": "03:45:20.591", "event": "skip", "reason": "pas_assez_de_parole", "speech_s": 0}
C'est cet outil qui a permis d'identifier la majorité des problèmes de la Phase 3 : attaques tronquées (transcript vide avec un bon RMS), hallucinations sur quasi-silence, homophones corrigés à tort, et le verdict base vs small en conditions réelles. Le diagnostic se fait sur des données objectives — pas sur la description du symptôme.
Tests automatisés
| Suite | Contenu | Nombre |
|---|---|---|
make test-unit | VAD, alignement, prononciation (tolérances, homophones), syllabation, découpage en unités, closest token | 73 |
make test-integration | Sessions WebSocket par mode (word, syllable, sentence, batch, stream) avec audio TTS réel | 17 |
make test-ui | Playwright (frontend + backend lancés) | 9 |