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-speech— Instala con `npx expo install expo-speech`.@expo/vector-icons— Viene 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:
- Resuelve `language` / `voice` con `resolveSpeechOptions(idiomaApp, texto)`.
- Opcional: alerta si `missingUrduVoice` para avisar de un fallback genérico.
- Pasa solo `language` / `voice` definidos. En iOS con ruta urdu, usa `useApplicationAudioSession: false` si arregla la voz del sistema.
- 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.