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
93 changes: 93 additions & 0 deletions src/hooks/useSpeechRecognition.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
73 changes: 70 additions & 3 deletions src/hooks/useSpeechRecognition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
onFinalRef.current = onFinal;
Expand Down Expand Up @@ -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();
}
};

Expand All @@ -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();
Expand All @@ -174,20 +232,29 @@ 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 {
rec.abort();
} catch {
// ignore
}
}, []);
}, [clearRestartTimer]);

useEffect(() => {
return () => {
wantListeningRef.current = false;
clearRestartTimer();
const rec = recognitionRef.current;
if (rec) {
try {
Expand All @@ -197,7 +264,7 @@ export function useSpeechRecognition({
}
}
};
}, []);
}, [clearRestartTimer]);

return { start, abort, isListening, error, isSupported };
}
13 changes: 12 additions & 1 deletion src/screens/SessionScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -408,7 +419,7 @@ export default function SessionScreen({
<button
type="button"
className="session-input-switch"
onClick={() => setInputMode('voice')}
onClick={switchToVoice}
disabled={numpadDisabled}
>
{'🎤'} Utiliser le micro
Expand Down
Loading