Funciones de la app

Escuchar en voz alta y resaltar palabras

Usa expo-speech para leer un texto en voz alta y resaltar cada palabra durante la reproducción. Los fragmentos siguen el estilo de esta documentación: cópialos en tu proyecto y adapta rutas, estilos y claves i18n (`src/utils/speechVoice.ts` es una ubicación habitual para el resolvedor).

Vídeo de demostración

Breve grabación de la pantalla de ejemplo: reproducir / pausa / detener y resaltado de palabras mientras el motor habla.

Pantalla de ejemplo completa

Un solo archivo listo para una app Expo: un párrafo, botones Reproducir / Pausa / Detener, resaltado palabra a palabra y estilos claro/oscuro. En Android la API no ofrece pausa: el ejemplo detiene la voz (ver comentarios en el archivo).

  • expo-speechInstala con `npx expo install expo-speech`.
  • @expo/vector-iconsViene con Expo (Ionicons). Sin instalación extra.

Dependencias y archivos

  • expo-speech`Speech.speak`, `Speech.stop`, `Speech.getAvailableVoicesAsync`. Instala con tu toolchain Expo (`npx expo install expo-speech`).
  • Tu pantalla o componente — UI del texto que lees: ayudas de segmentación y `Speech.speak` enlazados a una cadena recortada. Usa el archivo que encaje con tu router (p. ej. `app/article.tsx` o `src/screens/ReaderScreen.tsx`).
  • src/utils/speechVoice.ts`resolveSpeechOptions`, ruta urdu y elección de una voz urdu instalada cuando el idioma o el texto lo requieren.

Segmentación de palabras y límites

La segmentación usa la expresión `(\S+)(\s*)` para que cada token conserve los espacios finales. `readBoundaryCharIndex` lee el número `charIndex` de eventos nativos de límite y de cargas de síntesis web. `wordIndexFromBoundaryCharIndex` convierte el desplazamiento del TTS en índice de segmento.

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;
}

Segmentos memoizados y estado de UI

Memoiza los segmentos del texto recortado para alinear render y límites cuando cambie el contenido. Guarda si hay reproducción y qué índice de palabra resaltar.

const wordSegments = useMemo(
  () => segmentReadableWords(textTrimmed),
  [textTrimmed]
);

const [isSpeaking, setIsSpeaking] = useState(false);
const [speechHighlightWordIndex, setSpeechHighlightWordIndex] = useState<number | null>(null);

Detener voz y limpiar resaltado

Centraliza la limpieza: detén el motor y borra el resaltado. Ejecuta al desmontar y al cambiar el texto de origen para no dejar reproducción o resaltado obsoleto.

const stopReadAloud = useCallback(() => {
  Speech.stop();
  setIsSpeaking(false);
  setSpeechHighlightWordIndex(null);
}, []);

useEffect(() => {
  return () => {
    Speech.stop();
  };
}, []);

useEffect(() => {
  stopReadAloud();
}, [sourceText, stopReadAloud]);

Play/stop y Speech.speak

Flujo típico:

  1. Resuelve `language` / `voice` con `resolveSpeechOptions(idiomaApp, texto)`.
  2. Opcional: alerta si `missingUrduVoice` para avisar de un fallback genérico.
  3. Pasa solo `language` / `voice` definidos. En iOS con ruta urdu, usa `useApplicationAudioSession: false` si arregla la voz del sistema.
  4. En `onBoundary`, lee `charIndex`, mapea al índice de palabra y actualiza el resaltado; limpia en `onDone`, `onStopped` y `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);
    },
  });
};

Resolución de voz e idioma

En `speechVoice.ts` suele haber:

  • Tratar como urdu cuando el idioma de la app es `ur`, o el texto usa escritura árabe salvo que la app ya sea `ar`.
  • En iOS con voz urdu elegida, pasa solo `voice` (identificador de `getAvailableVoicesAsync`).
  • En Android, pasa `language: 'ur'` más `voice`.
  • En web, pasa `language` derivado de la voz más `voice` si existe.
  • Si no hay voz urdu, usa solo `language` y `missingUrduVoice: true` para avisar en 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: botón y Text anidados

Botón redondo play/stop junto al texto. Renderiza el pasaje como hijos `Text` anidados: el segmento activo usa tu estilo de resaltado.

<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>

Estilo de resaltado

// e.g. in your StyleSheet
wordHighlighted: {
  backgroundColor: colors.accentMuted!,
  color: colors.textPrimary!,
  borderRadius: Utility.SP_6,
  overflow: 'hidden',
  fontWeight: '700',
},

Limitaciones

  • Algunos motores TTS en Android emiten `onBoundary` poco uniforme; el resaltado puede retrasarse o saltar.
  • Los límites son por espacios; idiomas sin espacios entre palabras no encajarán bien.
  • Los simuladores suelen carecer de voces no inglesas; prueba en dispositivo con voces del sistema.

Patrocinado

Promoción breve