React Native Flow

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-speechInstallez avec `npx expo install expo-speech`.
  • @expo/vector-iconsInclus 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 :

  1. Résolvez `language` / `voice` avec `resolveSpeechOptions(langueApp, texte)`.
  2. Optionnel : alertez si `missingUrduVoice` pour signaler un repli générique.
  3. Ne passez que `language` / `voice` définis. Sur iOS avec routage ourdou, `useApplicationAudioSession: false` peut corriger la voix système.
  4. 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.

Sponsorisé

Promo rapide