diff --git a/src/__tests__/mixedSessionTTS.test.tsx b/src/__tests__/mixedSessionTTS.test.tsx new file mode 100644 index 00000000..5f09e8ed --- /dev/null +++ b/src/__tests__/mixedSessionTTS.test.tsx @@ -0,0 +1,98 @@ +import { cleanup, fireEvent, render } from '@testing-library/preact'; +import { afterEach, describe, expect, it } from 'vitest'; +import SessionScreen from '../screens/SessionScreen'; +import type { SessionItem, MultiFact, DivisionFact } from '../types'; + +// Régression : la lecture TTS de la question ne doit jamais être perdue parce +// que l'enfant répond avant la fin du décodage du MP3. Le SessionScreen +// précharge l'audio de la séance à l'ouverture ; `speak` démarre alors par un +// chemin synchrone (buffer en cache), increvable par le stop() de la réponse. +// Sans le préchargement, une réponse rapide annulait une lecture jamais +// démarrée — symptôme observé en séance mixte (on entendait les divisions, +// plus lentes à répondre, mais pas les tables connues répondues au quart de +// tour). On modélise une latence de premier décodage et un enfant qui répond +// plus vite que cette latence, puis on vérifie que chaque question est bien lue. + +function mult(a: number, b: number): SessionItem { + const fact: MultiFact = { + a, b, product: a * b, box: 3, lastSeen: '', nextDue: '', history: [], introduced: true, + }; + return { kind: 'mult', fact, displayA: a, displayB: b, isIntroduction: false, isRetry: false, isBonusReview: false }; +} +function div(dividend: number, divisor: number): SessionItem { + const fact: DivisionFact = { + dividend, divisor, quotient: dividend / divisor, box: 3, lastSeen: '', nextDue: '', history: [], introduced: true, + }; + return { kind: 'div', fact, isIntroduction: false, isRetry: false, isBonusReview: false }; +} + +function typeAnswer(value: number): void { + const digits = value.toString(); + for (const d of digits) { + const btn = document.querySelector(`.numpad-btn[aria-label="${d}"]`); + if (!btn) throw new Error(`NumPad ${d} introuvable`); + fireEvent.click(btn); + } + if (digits.length === 1) { + const ok = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'OK'); + fireEvent.click(ok!); + } +} +function dismissFeedback(): void { + const overlay = document.querySelector('[class*="feedback"]'); + if (overlay) fireEvent.click(overlay); +} +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +afterEach(() => cleanup()); + +describe('Séance mixte — lecture audio des questions', () => { + it('un enfant qui répond vite ne perd pas la lecture audio des questions', async () => { + // Simulate realistic first-load latency on the TTS fetch. + const origFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/audio/tts/')) { + return wait(25).then(() => new Response(new ArrayBuffer(0), { status: 200 })); + } + return origFetch(input as RequestInfo, init); + }) as typeof fetch; + + let startCount = 0; + const AC = globalThis.AudioContext as unknown as { prototype: { createBufferSource: () => AudioBufferSourceNode } }; + const origCBS = AC.prototype.createBufferSource; + AC.prototype.createBufferSource = function (this: AudioContext) { + const node = origCBS.call(this); + const origStart = node.start.bind(node); + node.start = (...args: Parameters) => { + startCount++; + return origStart(...args); + }; + return node; + }; + + const questions: SessionItem[] = [ + mult(2, 2), div(8, 2), mult(5, 3), div(15, 3), mult(8, 8), div(64, 8), mult(6, 9), + ]; + render( {}} onAnswer={() => {}} />); + + // Brief settle while the session preloads its audio (a child can't answer + // the very first question in <25ms anyway). + await wait(60); + + // Child who already knows the facts: answers ~10ms after each question + // appears — faster than the 25ms first-load of its audio. + for (const q of questions) { + await wait(10); + const answer = q.kind === 'div' ? q.fact.quotient : q.fact.product; + typeAnswer(answer); + dismissFeedback(); + } + await wait(40); + + AC.prototype.createBufferSource = origCBS; + globalThis.fetch = origFetch; + + expect(startCount).toBe(questions.length); + }); +}); diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index ead50814..404426dd 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -55,27 +55,11 @@ export function useTTS() { const callGenRef = useRef(0); const [isSpeaking, setIsSpeaking] = useState(false); - const speak = useCallback((key: string, onEnd?: () => void) => { - const myGen = ++callGenRef.current; - - if (activeRef.current) { - activeRef.current.stopped = true; - try { activeRef.current.source.stop(); } catch { /* ignore */ } - activeRef.current = null; - } - - setIsSpeaking(true); - - void (async () => { - const ctx = getAudioContext(); - const buffer = await loadBuffer(key, ctx); - // Périmé : un speak() ou stop() plus récent a pris le relais. + // Démarre la lecture d'un buffer déjà décodé. No-op si l'appel est périmé + // (un speak/stop plus récent est passé entre-temps). + const startSource = useCallback( + (buffer: AudioBuffer, ctx: AudioContext, myGen: number, onEnd?: () => void) => { if (callGenRef.current !== myGen) return; - if (!buffer) { - activeRef.current = null; - setIsSpeaking(false); - return; - } const source = ctx.createBufferSource(); source.buffer = buffer; @@ -99,7 +83,78 @@ export function useTTS() { setIsSpeaking(false); } } - })(); + }, + [], + ); + + const playBuffer = useCallback( + (buffer: AudioBuffer, ctx: AudioContext, myGen: number, onEnd?: () => void) => { + if (callGenRef.current !== myGen) return; + + // En mode vocal, la reconnaissance (micro) peut, surtout sur iOS, + // suspendre/interrompre le contexte audio partagé : `source.start()` sur + // un contexte non `running` est alors silencieux. On le réveille d'abord. + // Si le contexte tourne déjà (cas courant), on reste sur le chemin + // synchrone — crucial pour que la lecture ne soit pas annulable par un + // stop() arrivant entre deux microtasks. + if (ctx.state !== 'running') { + ctx.resume().finally(() => startSource(buffer, ctx, myGen, onEnd)); + return; + } + startSource(buffer, ctx, myGen, onEnd); + }, + [startSource], + ); + + const speak = useCallback( + (key: string, onEnd?: () => void) => { + const myGen = ++callGenRef.current; + + if (activeRef.current) { + activeRef.current.stopped = true; + try { activeRef.current.source.stop(); } catch { /* ignore */ } + activeRef.current = null; + } + + setIsSpeaking(true); + + const ctx = getAudioContext(); + + // Chemin synchrone quand le buffer est déjà en cache (préchargé, ou + // rejoué dans la séance) : on démarre immédiatement, sans fenêtre async. + // Crucial — sinon un `await` cède la main d'ici le démarrage, et un + // stop() déclenché entre-temps (l'enfant répond avant d'avoir entendu + // une question qu'il connaît) annule une lecture jamais commencée. + const cached = bufferCache.get(key); + if (cached) { + playBuffer(cached, ctx, myGen, onEnd); + return; + } + + void (async () => { + const buffer = await loadBuffer(key, ctx); + // Périmé : un speak() ou stop() plus récent a pris le relais. + if (callGenRef.current !== myGen) return; + if (!buffer) { + activeRef.current = null; + setIsSpeaking(false); + return; + } + playBuffer(buffer, ctx, myGen, onEnd); + })(); + }, + [playBuffer], + ); + + // Préchargement : décode à l'avance les MP3 d'une liste de clés (typiquement + // toutes les questions d'une séance) pour que `speak` les démarre ensuite par + // le chemin synchrone ci-dessus. Idempotent et silencieux (les échecs sont + // gérés par loadBuffer → speak retombera sur le chemin async ou le silence). + const preload = useCallback((keys: string[]) => { + const ctx = getAudioContext(); + for (const key of keys) { + if (!bufferCache.has(key)) void loadBuffer(key, ctx); + } }, []); const stop = useCallback(() => { @@ -115,5 +170,5 @@ export function useTTS() { useEffect(() => stop, [stop]); - return { speak, stop, isSpeaking }; + return { speak, stop, preload, isSpeaking }; } diff --git a/src/screens/SessionScreen.tsx b/src/screens/SessionScreen.tsx index 21a2e817..a8b53913 100644 --- a/src/screens/SessionScreen.tsx +++ b/src/screens/SessionScreen.tsx @@ -94,7 +94,7 @@ export default function SessionScreen({ const submittingRef = useRef(false); const { playCorrect, playIncorrect } = useSound(); - const { speak, stop: stopSpeech, isSpeaking } = useTTS(); + const { speak, stop: stopSpeech, preload, isSpeaking } = useTTS(); const { inputMode, setInputMode } = useInputMode(); useWakeLock(true); @@ -107,11 +107,34 @@ export default function SessionScreen({ const speakQuestion = useCallback((item: SessionItem) => speak(view(item).qKey), [speak]); + // Précharge l'audio de toutes les questions de la séance dès son ouverture. + // Sans cela, le MP3 d'une question est décodé à la volée quand on l'atteint : + // un enfant qui connaît le fait peut répondre avant la fin de ce décodage, et + // le stop() de la réponse annule alors une lecture jamais démarrée — la + // question n'est jamais lue à voix haute (bug des séances mixtes où les + // divisions, plus lentes à répondre, s'entendaient mais pas les tables + // connues). Préchargé = `speak` démarre par le chemin synchrone, increvable. + useEffect(() => { + const keys = new Set(); + for (const item of initialQuestions) { + keys.add(view(item).qKey); + if (item.isIntroduction) { + keys.add( + item.kind === 'div' + ? `introd-${item.fact.dividend}-${item.fact.divisor}` + : `intro-${item.fact.a}-${item.fact.b}`, + ); + } + } + preload([...keys]); + // Une seule fois à l'ouverture : les retrys réutilisent des clés déjà en cache. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Ajuste l'état UI au changement de question (render-time, cf. ancien // SessionScreen — reset synchrone du guard anti double-submit). const [prevIndex, setPrevIndex] = useState(-1); if (currentIndex !== prevIndex && currentItem) { - // eslint-disable-next-line react-hooks/refs submittingRef.current = false; setPrevIndex(currentIndex); if (currentItem.isIntroduction) {