From 7bc8b9f9d7e4d65fce73f627223cab065371342d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 19:20:10 +0000 Subject: [PATCH 1/2] fix(voix): corrige le freeze iOS au premier usage du micro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur iOS, au tout premier octroi de permission, WebKit termine parfois la reconnaissance immédiatement (onend) sans émettre onstart ni résultat. Comme le hook 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 l'appli. Au 2e lancement, la permission déjà accordée, tout fonctionnait. - useSpeechRecognition: garde-fou anti-boucle. On détecte les cycles anormalement courts (terminés presque aussitôt démarrés, sans résultat), on temporise avant de retenter, et on coupe au bout de 5 cycles à vide consécutifs plutôt que de figer le thread principal. - SessionScreen: le switch clavier → micro en cours de séance pré-arme désormais la permission (preflightMicPermission) en consommant le geste du clic, comme au démarrage de séance, pour éviter de déclencher le glitch. - Test de non-régression du garde-fou (simule le glitch iOS). https://claude.ai/code/session_01RaEog9SSJeZJZA63kYoyD9 --- src/hooks/useSpeechRecognition.test.ts | 64 ++++++++++++++++++++++++ src/hooks/useSpeechRecognition.ts | 68 +++++++++++++++++++++++++- src/screens/SessionScreen.tsx | 13 ++++- 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useSpeechRecognition.test.ts diff --git a/src/hooks/useSpeechRecognition.test.ts b/src/hooks/useSpeechRecognition.test.ts new file mode 100644 index 00000000..5ce3230f --- /dev/null +++ b/src/hooks/useSpeechRecognition.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/preact'; +import { useSpeechRecognition } from './useSpeechRecognition'; + +// Reproduit 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. Ce double simule ce comportement et compte les start(). +let startCount = 0; + +class FakeRecognition { + 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; + // onend async (comme un vrai navigateur), aussitôt, sans onstart : glitch. + setTimeout(() => { + this.onend?.(); + }, 0); + } + + abort(): void {} +} + +describe('useSpeechRecognition — garde-fou anti-boucle iOS', () => { + beforeEach(() => { + startCount = 0; + 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 < 100; 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); + }); +}); diff --git a/src/hooks/useSpeechRecognition.ts b/src/hooks/useSpeechRecognition.ts index cf6da95d..a28cafd3 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(); @@ -176,6 +234,10 @@ export function useSpeechRecognition({ const abort = useCallback(() => { wantListeningRef.current = false; + if (restartTimerRef.current) { + clearTimeout(restartTimerRef.current); + restartTimerRef.current = null; + } const rec = recognitionRef.current; if (!rec) return; try { @@ -188,6 +250,10 @@ export function useSpeechRecognition({ useEffect(() => { return () => { wantListeningRef.current = false; + if (restartTimerRef.current) { + clearTimeout(restartTimerRef.current); + restartTimerRef.current = null; + } const rec = recognitionRef.current; if (rec) { try { 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({