From d55e44d46eaf4d104113d5d1dc8a8cb9ce67b3cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 17:50:19 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(tts):=20pr=C3=A9charge=20l'audio=20des?= =?UTF-8?q?=20questions=20pour=20fiabiliser=20la=20lecture=20=C3=A0=20voix?= =?UTF-8?q?=20haute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En séance mixte, la lecture TTS d'une question pouvait être silencieusement perdue : le MP3 était décodé à la volée à l'arrivée sur la question, et si l'enfant répondait avant la fin du décodage (typique d'un fait connu, répondu au quart de tour), le stop() de la réponse annulait une lecture jamais démarrée. Les divisions, plus lentes à répondre, s'entendaient ; les tables connues, non. Correctif : - SessionScreen précharge l'audio de toutes les questions dès l'ouverture de la séance (questions + intros). - useTTS expose preload() et démarre la lecture par un chemin synchrone quand le buffer est déjà en cache — plus de fenêtre async annulable par un stop(). Test de régression : un enfant qui répond plus vite que la latence de premier décodage entend bien chaque question. --- src/__tests__/mixedSessionTTS.test.tsx | 98 ++++++++++++++++++++++++++ src/hooks/useTTS.ts | 80 +++++++++++++++------ src/screens/SessionScreen.tsx | 27 ++++++- 3 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/mixedSessionTTS.test.tsx 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..37827280 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 playBuffer = 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,59 @@ export function useTTS() { setIsSpeaking(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); + + 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 +151,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) { From e97bc1987bfbc4ea3ef2cf7fa9a214339ea7f97e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 18:42:50 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(tts):=20r=C3=A9veille=20l'AudioContext?= =?UTF-8?q?=20avant=20de=20jouer=20(contention=20micro=20en=20vocal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En mode vocal, la reconnaissance (micro) peut suspendre ou « interrompre » (iOS) le contexte audio partagé ; source.start() sur un contexte non `running` est alors silencieux — une question sur deux passait inaperçue. On résume le contexte avant la lecture quand il n'est pas en cours. Le cas `running` (courant) reste sur le chemin synchrone, donc la lecture demeure increvable par un stop() concurrent. --- src/hooks/useTTS.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/hooks/useTTS.ts b/src/hooks/useTTS.ts index 37827280..404426dd 100644 --- a/src/hooks/useTTS.ts +++ b/src/hooks/useTTS.ts @@ -57,7 +57,7 @@ export function useTTS() { // 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 playBuffer = useCallback( + const startSource = useCallback( (buffer: AudioBuffer, ctx: AudioContext, myGen: number, onEnd?: () => void) => { if (callGenRef.current !== myGen) return; @@ -87,6 +87,25 @@ export function useTTS() { [], ); + 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;