diff --git a/src/hooks/useSpeechRecognition.test.ts b/src/hooks/useSpeechRecognition.test.ts new file mode 100644 index 00000000..8d7918d7 --- /dev/null +++ b/src/hooks/useSpeechRecognition.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/preact'; +import { useSpeechRecognition } from './useSpeechRecognition'; + +// Double minimal de l'API SpeechRecognition. `autoEnd` rejoue le glitch iOS : +// au tout premier octroi de permission, WebKit termine la reconnaissance +// immédiatement (onend) sans jamais émettre onstart ni résultat. Le hook +// redémarre dans onend pour garder le micro ouvert toute la séance — sans +// garde-fou, on obtenait une boucle start→end→start→end qui figeait l'UI. +let startCount = 0; +let lastInstance: FakeRecognition | null = null; + +class FakeRecognition { + // true → onend immédiat à chaque start (glitch iOS) ; false → on pilote + // onend à la main pour simuler un cycle qui a réellement duré. + static autoEnd = true; + lang = ''; + interimResults = false; + continuous = false; + maxAlternatives = 0; + onstart: (() => void) | null = null; + onresult: ((ev: unknown) => void) | null = null; + onerror: ((ev: unknown) => void) | null = null; + onend: (() => void) | null = null; + + start(): void { + startCount += 1; + // eslint-disable-next-line @typescript-eslint/no-this-alias -- le test pilote onend sur l'instance vivante + lastInstance = this; + if (FakeRecognition.autoEnd) { + // onend async (comme un vrai navigateur), aussitôt, sans onstart. + setTimeout(() => { + this.onend?.(); + }, 0); + } + } + + abort(): void {} +} + +describe('useSpeechRecognition — garde-fou anti-boucle iOS', () => { + beforeEach(() => { + startCount = 0; + lastInstance = null; + FakeRecognition.autoEnd = true; + vi.useFakeTimers(); + (window as unknown as { SpeechRecognition: unknown }).SpeechRecognition = FakeRecognition; + }); + + afterEach(() => { + vi.useRealTimers(); + delete (window as unknown as { SpeechRecognition?: unknown }).SpeechRecognition; + delete (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition; + }); + + it("ne boucle pas indéfiniment quand chaque cycle se termine aussitôt (glitch iOS)", () => { + const { result } = renderHook(() => useSpeechRecognition({ onFinal: () => {} })); + + act(() => { + result.current.start(); + }); + + // Laisse largement le temps à une boucle non bridée de partir en vrille. + for (let i = 0; i < 10; i++) { + act(() => { + vi.advanceTimersByTime(500); + }); + } + + // Le garde-fou plafonne les redémarrages à vide (MAX_RAPID_RESTARTS = 5). + expect(startCount).toBeLessThanOrEqual(5); + expect(startCount).toBeGreaterThan(0); + }); + + it('redémarre immédiatement après un cycle qui a réellement duré (chemin nominal)', () => { + // Pas de glitch : on pilote onend à la main après un long délai. + FakeRecognition.autoEnd = false; + const { result } = renderHook(() => useSpeechRecognition({ onFinal: () => {} })); + + act(() => { + result.current.start(); + }); + expect(startCount).toBe(1); + + // Le cycle a tourné > RAPID_RESTART_THRESHOLD_MS avant de se terminer : + // ce n'est pas un cycle « à vide », donc redémarrage immédiat, non bridé. + act(() => { + vi.advanceTimersByTime(2000); + lastInstance?.onend?.(); + }); + expect(startCount).toBe(2); + }); +}); diff --git a/src/hooks/useSpeechRecognition.ts b/src/hooks/useSpeechRecognition.ts index cf6da95d..082812bf 100644 --- a/src/hooks/useSpeechRecognition.ts +++ b/src/hooks/useSpeechRecognition.ts @@ -77,6 +77,20 @@ export type SpeechRecognitionError = | 'bad-grammar' | (string & {}); +// Garde-fou iOS : au tout premier octroi de permission, WebKit termine parfois +// la reconnaissance immédiatement (onend) sans jamais émettre onstart ni +// résultat. Comme on redémarre dans onend pour garder le micro ouvert toute la +// séance, on obtenait une boucle start→end→start→end qui saturait la boucle +// d'événements et figeait toute l'UI (l'utilisateur devait force-quitter). +// On considère qu'un cycle ayant duré moins que ce seuil est « à vide ». +const RAPID_RESTART_THRESHOLD_MS = 500; +// Au-delà de ce nombre de cycles à vide consécutifs, on arrête de retenter : +// la prochaine séance repartira proprement, permission déjà accordée. +const MAX_RAPID_RESTARTS = 5; +// Délai laissé au navigateur entre deux tentatives quand un cycle à vide est +// détecté, pour ne pas re-saturer la boucle d'événements. +const RESTART_BACKOFF_MS = 400; + export function useSpeechRecognition({ onFinal, onInterim, @@ -88,6 +102,11 @@ export function useSpeechRecognition({ const wantListeningRef = useRef(false); const onFinalRef = useRef(onFinal); const onInterimRef = useRef(onInterim); + // Horodatage du dernier start() effectif + compteur de cycles « à vide » + // consécutifs + timer de backoff : voir le garde-fou iOS dans onend. + const lastStartAtRef = useRef(0); + const emptyRestartCountRef = useRef(0); + const restartTimerRef = useRef | null>(null); useEffect(() => { onFinalRef.current = onFinal; @@ -146,12 +165,47 @@ export function useSpeechRecognition({ rec.onend = () => { setIsListening(false); - if (wantListeningRef.current) { + if (!wantListeningRef.current) return; + + // Un cycle anormalement court (terminé presque aussitôt démarré, sans + // résultat) trahit le glitch iOS du premier octroi de permission. On + // les compte ; au-delà d'un seuil on coupe la boucle plutôt que de + // figer le thread principal. + const ranForMs = Date.now() - lastStartAtRef.current; + if (ranForMs < RAPID_RESTART_THRESHOLD_MS) { + emptyRestartCountRef.current += 1; + } else { + emptyRestartCountRef.current = 0; + } + + if (emptyRestartCountRef.current >= MAX_RAPID_RESTARTS) { + wantListeningRef.current = false; + emptyRestartCountRef.current = 0; + return; + } + + const restart = () => { + if (!wantListeningRef.current) return; + lastStartAtRef.current = Date.now(); try { rec.start(); } catch { // start() throws if already started — ignore } + }; + + if (emptyRestartCountRef.current > 0) { + // Cycle à vide détecté : on laisse respirer le navigateur avant de + // retenter, pour ne pas re-saturer la boucle d'événements. + if (restartTimerRef.current) clearTimeout(restartTimerRef.current); + restartTimerRef.current = setTimeout(() => { + restartTimerRef.current = null; + restart(); + }, RESTART_BACKOFF_MS); + } else { + // Chemin nominal : redémarrage immédiat pour ne pas manquer une + // réponse enchaînée rapidement entre deux questions. + restart(); } }; @@ -166,6 +220,10 @@ export function useSpeechRecognition({ return; } wantListeningRef.current = true; + // Démarrage explicite (geste utilisateur / nouvelle séance) : on repart + // d'un compteur vierge pour le garde-fou anti-boucle. + emptyRestartCountRef.current = 0; + lastStartAtRef.current = Date.now(); setError(null); try { rec.start(); @@ -174,8 +232,16 @@ export function useSpeechRecognition({ } }, [ensureRecognition]); + const clearRestartTimer = useCallback(() => { + if (restartTimerRef.current) { + clearTimeout(restartTimerRef.current); + restartTimerRef.current = null; + } + }, []); + const abort = useCallback(() => { wantListeningRef.current = false; + clearRestartTimer(); const rec = recognitionRef.current; if (!rec) return; try { @@ -183,11 +249,12 @@ export function useSpeechRecognition({ } catch { // ignore } - }, []); + }, [clearRestartTimer]); useEffect(() => { return () => { wantListeningRef.current = false; + clearRestartTimer(); const rec = recognitionRef.current; if (rec) { try { @@ -197,7 +264,7 @@ export function useSpeechRecognition({ } } }; - }, []); + }, [clearRestartTimer]); return { start, abort, isListening, error, isSupported }; } diff --git a/src/screens/SessionScreen.tsx b/src/screens/SessionScreen.tsx index a8b53913..d70d33df 100644 --- a/src/screens/SessionScreen.tsx +++ b/src/screens/SessionScreen.tsx @@ -16,6 +16,7 @@ import { useSound } from '../hooks/useSound'; import { useTTS } from '../hooks/useTTS'; import { useInputMode } from '../hooks/useInputMode'; import { isSpeechRecognitionSupported } from '../hooks/useSpeechRecognition'; +import { preflightMicPermission } from '../lib/micPreflight'; import { useWakeLock } from '../hooks/useWakeLock'; // Borne dure sur la longueur d'une session : la composition vise 12-15 @@ -98,6 +99,16 @@ export default function SessionScreen({ const { inputMode, setInputMode } = useInputMode(); useWakeLock(true); + // Bascule clavier → micro en cours de séance. On pré-arme la permission en + // consommant le geste du clic (requis par iOS) AVANT de monter VoiceInput : + // sinon le tout premier SpeechRecognition.start() coïncide avec le prompt + // natif et, sur iOS, déclenche le glitch « onend immédiat » au premier + // octroi. On reproduit ici le preflight déjà fait au démarrage de séance. + const switchToVoice = useCallback(async () => { + await preflightMicPermission(); + setInputMode('voice'); + }, [setInputMode]); + const questionStartTime = useRef(0); const correctCount = useRef(0); const totalTimeMs = useRef(0); @@ -408,7 +419,7 @@ export default function SessionScreen({