Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/__tests__/mixedSessionTTS.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(`.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<HTMLElement>('[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<AudioBufferSourceNode['start']>) => {
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(<SessionScreen questions={questions} onComplete={() => {}} 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);
});
});
99 changes: 77 additions & 22 deletions src/hooks/useTTS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(() => {
Expand All @@ -115,5 +170,5 @@ export function useTTS() {

useEffect(() => stop, [stop]);

return { speak, stop, isSpeaking };
return { speak, stop, preload, isSpeaking };
}
27 changes: 25 additions & 2 deletions src/screens/SessionScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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<string>();
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) {
Expand Down
Loading