Fonctionnalités de l'app
Lecture vocale et surlignage mot à mot
Avec expo-speech, lisez un passage à voix haute et surlignez chaque mot pendant la lecture. Les extraits suivent le style du reste de la doc : copiez-les dans votre projet Expo, puis adaptez chemins, styles et clés i18n (`src/utils/speechVoice.ts` est un emplacement courant pour le résolveur).
Vidéo de démonstration
Court enregistrement d’écran de l’exemple : lecture / pause / arrêt et surlignage des mots pendant la synthèse vocale.
Écran d’exemple complet
Un seul fichier pour une app Expo : un paragraphe, lecture / pause / arrêt, surlignage mot à mot et styles clair/sombre. Sous Android l’API ne propose pas la pause : l’exemple arrête la voix (voir les commentaires dans le fichier).
expo-speech— Installez avec `npx expo install expo-speech`.@expo/vector-icons— Inclus avec Expo (Ionicons). Pas d’install supplémentaire.
Dépendances et fichiers
expo-speech— `Speech.speak`, `Speech.stop`, `Speech.getAvailableVoicesAsync`. Installez avec votre chaîne Expo (`npx expo install expo-speech`).- Votre écran ou composant — UI du texte lu : helpers de segmentation et `Speech.speak` liés à une chaîne rognée. Utilisez le fichier qui correspond à votre routeur (p. ex. `app/article.tsx` ou `src/screens/ReaderScreen.tsx`).
src/utils/speechVoice.ts— `resolveSpeechOptions`, routage ourdou et choix d’une voix ourdoue installée quand la langue ou le texte l’exigent.
Segmentation et frontières de mots
La segmentation utilise `(\S+)(\s*)` pour garder les espaces après chaque token. `readBoundaryCharIndex` lit le nombre `charIndex` des événements natifs de frontière et des charges de synthèse web. `wordIndexFromBoundaryCharIndex` mappe l’offset TTS vers un index de segment.
type ReadableWordSeg = { word: string; sep: string; start: number };
function segmentReadableWords(text: string): ReadableWordSeg[] {
const out: ReadableWordSeg[] = [];
const re = /(\S+)(\s*)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
out.push({ word: m[1], sep: m[2], start: m.index });
}
return out;
}
function wordIndexFromBoundaryCharIndex(text: string, charIndex: number): number {
const segs = segmentReadableWords(text);
if (segs.length === 0) return 0;
const idx = Math.max(0, Math.min(charIndex, Math.max(0, text.length - 1)));
for (let i = 0; i < segs.length; i++) {
const { start, word } = segs[i];
if (idx >= start && idx < start + word.length) return i;
}
for (let i = 0; i < segs.length - 1; i++) {
const gapStart = segs[i].start + segs[i].word.length;
const gapEnd = segs[i + 1].start;
if (idx >= gapStart && idx < gapEnd) return i + 1;
}
return segs.length - 1;
}
function readBoundaryCharIndex(ev: unknown): number {
if (
ev &&
typeof ev === 'object' &&
'charIndex' in ev &&
typeof (ev as { charIndex: unknown }).charIndex === 'number'
) {
return (ev as { charIndex: number }).charIndex;
}
return 0;
}Segments mémorisés et état UI
Mémorisez les segments du texte rogné pour garder le rendu aligné quand le contenu change. Suivez la lecture et l’index du mot surligné.
const wordSegments = useMemo(
() => segmentReadableWords(textTrimmed),
[textTrimmed]
);
const [isSpeaking, setIsSpeaking] = useState(false);
const [speechHighlightWordIndex, setSpeechHighlightWordIndex] = useState<number | null>(null);Arrêt de la voix et reset du surlignage
Centralisez l’arrêt : stoppez le moteur et effacez le surlignage. Appelez cela au démontage et quand le texte source change pour ne pas garder lecture ou surlignage obsolète.
const stopReadAloud = useCallback(() => {
Speech.stop();
setIsSpeaking(false);
setSpeechHighlightWordIndex(null);
}, []);
useEffect(() => {
return () => {
Speech.stop();
};
}, []);
useEffect(() => {
stopReadAloud();
}, [sourceText, stopReadAloud]);Lecture / arrêt et Speech.speak
Flux typique :
- Résolvez `language` / `voice` avec `resolveSpeechOptions(langueApp, texte)`.
- Optionnel : alertez si `missingUrduVoice` pour signaler un repli générique.
- Ne passez que `language` / `voice` définis. Sur iOS avec routage ourdou, `useApplicationAudioSession: false` peut corriger la voix système.
- Dans `onBoundary`, lisez `charIndex`, mappez vers l’index de mot et mettez à jour le surlignage ; effacez dans `onDone`, `onStopped` et `onError`.
// import * as Speech from 'expo-speech';
// import { Alert, Platform } from 'react-native';
// import type { ResolvedSpeechOptions } from '@/utils/speechVoice';
// import { resolveSpeechOptions } from '@/utils/speechVoice';
const onToggleReadAloud = async () => {
if (!hasText) return;
if (isSpeaking) {
stopReadAloud();
return;
}
const text = textTrimmed;
Speech.stop();
setIsSpeaking(true);
setSpeechHighlightWordIndex(0);
let resolved: ResolvedSpeechOptions;
try {
resolved = await resolveSpeechOptions(language, text);
} catch {
stopReadAloud();
return;
}
if (resolved.missingUrduVoice) {
Alert.alert(t('alerts.urduVoiceTitle'), t('alerts.urduVoiceMessage'));
}
Speech.speak(text, {
...(resolved.language != null ? { language: resolved.language } : {}),
...(resolved.voice != null ? { voice: resolved.voice } : {}),
...(Platform.OS === 'ios' && resolved.urduRoute ? { useApplicationAudioSession: false } : {}),
pitch: 1,
rate: 0.96,
onStart: () => setSpeechHighlightWordIndex(0),
onBoundary: (ev: unknown) => {
const ci = readBoundaryCharIndex(ev);
setSpeechHighlightWordIndex(wordIndexFromBoundaryCharIndex(text, ci));
},
onDone: () => {
setIsSpeaking(false);
setSpeechHighlightWordIndex(null);
},
onStopped: () => {
setIsSpeaking(false);
setSpeechHighlightWordIndex(null);
},
onError: () => {
setIsSpeaking(false);
setSpeechHighlightWordIndex(null);
},
});
};Résolution de voix et de langue
Dans `speechVoice.ts`, on trouve souvent :
- Ourdou si la langue d’app est `ur`, ou si le texte est en écriture arabe sauf si l’app est déjà `ar`.
- Sur iOS avec voix ourdoue choisie, passez uniquement `voice` (identifiant de `getAvailableVoicesAsync`).
- Sur Android, passez `language: 'ur'` et `voice`.
- Sur le web, passez `language` dérivé de la voix plus `voice` si disponible.
- Sans voix ourdoue, repli sur `language` seul et `missingUrduVoice: true` pour l’UI.
import * as Speech from 'expo-speech';
import { Platform } from 'react-native';
export type AppLanguage = 'en' | 'ur' | 'ar' | string;
export type ResolvedSpeechOptions = {
language?: string;
voice?: string;
urduRoute: boolean;
missingUrduVoice?: boolean;
};
function hasArabicScript(text: string): boolean {
return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
}
export function shouldUseUrduSpeech(appLang: AppLanguage, text: string): boolean {
if (appLang === 'ur') return true;
if (appLang === 'ar') return false;
return hasArabicScript(text);
}
function localeTagForAppLanguage(appLang: AppLanguage): string {
const map: Record<string, string> = { en: 'en-US', ur: 'ur-PK', ar: 'ar' };
return map[appLang] ?? appLang;
}
type VoiceInfo = { identifier: string; language: string; name?: string };
/** Rank available voices; tune for your product. */
export function pickUrduVoice(voices: VoiceInfo[]): VoiceInfo | null {
const candidates = voices.filter(
(v) =>
/^ur/i.test(v.language) ||
/urd/i.test(v.language) ||
/urdu/i.test(v.name ?? '')
);
if (candidates.length === 0) return null;
const rank = (lang: string) => {
const l = lang.toLowerCase().replace(/_/g, '-');
if (l.includes('pk')) return 3;
if (l === 'ur' || l.startsWith('ur-')) return 2;
return 1;
};
return [...candidates].sort((a, b) => rank(b.language) - rank(a.language))[0];
}
export async function resolveSpeechOptions(
appLang: AppLanguage,
text: string
): Promise<ResolvedSpeechOptions> {
const defaultLang = localeTagForAppLanguage(appLang);
if (!shouldUseUrduSpeech(appLang, text)) {
return { language: defaultLang, urduRoute: false };
}
let missingUrduVoice = false;
try {
const voices = await Speech.getAvailableVoicesAsync();
const picked = pickUrduVoice(voices);
if (picked) {
if (Platform.OS === 'ios') {
return { voice: picked.identifier, urduRoute: true };
}
if (Platform.OS === 'android') {
return { language: 'ur', voice: picked.identifier, urduRoute: true };
}
return { language: picked.language.replace(/_/g, '-'), voice: picked.identifier, urduRoute: true };
}
missingUrduVoice = true;
} catch {
missingUrduVoice = true;
}
return {
language: Platform.OS === 'android' ? 'ur' : 'ur-PK',
urduRoute: true,
missingUrduVoice,
};
}UI : bouton play/stop et Text imbriqués
Bouton rond play/stop à côté du passage. Rendez le texte en `Text` imbriqués : le segment actif utilise votre style de surlignage.
<Pressable
onPress={onToggleReadAloud}
style={({ pressed }) => [
styles.readAloudButton,
busy && styles.readAloudButtonDisabled,
pressed && !busy && styles.readAloudButtonPressed,
]}
disabled={busy}
accessibilityRole="button"
accessibilityState={{ busy: isSpeaking }}
accessibilityLabel={
isSpeaking ? t('a11y.stopSpeech') : t('a11y.listenAloud')
}
>
<Ionicons
name={isSpeaking ? 'stop' : 'play'}
size={22}
color={colors.accentText}
/>
</Pressable>
<Text style={styles.bodyText}>
{wordSegments.map((seg, i) => (
<Text
key={`${seg.start}-${i}`}
style={
isSpeaking && speechHighlightWordIndex === i
? styles.wordHighlighted
: undefined
}
>
{seg.word}
{seg.sep}
</Text>
))}
</Text>Style de surlignage
// e.g. in your StyleSheet
wordHighlighted: {
backgroundColor: colors.accentMuted!,
color: colors.textPrimary!,
borderRadius: Utility.SP_6,
overflow: 'hidden',
fontWeight: '700',
},Limites
- Certains moteurs TTS Android émettent des `onBoundary` irréguliers ; le surlignage peut prendre du retard ou sauter.
- Les frontières suivent les espaces ; sans espaces entre mots, l’alignement est difficile.
- Les simulateurs manquent souvent de voix non anglaises ; testez sur appareil avec voix système installées.