Speech
Read a message, a hint or an article aloud, with a choice of voice, language, pitch and rate.
@symbiote-native/speech wraps expo-speech
so every SymbioteNative adapter can reach it, not just React. Every export is a stateless function
or constant, so the React, Vue, Angular, Svelte, and Solid entry points are plain re-exports of the
same core.
| OS platform | Support |
|---|---|
| iOS | live |
| Android | live |
| Framework adapter | Support |
|---|---|
| React | live |
| Vue | live |
| Angular | live |
| Svelte | live |
| Solid | live |
Installation
Section titled “Installation”npm install @symbiote-native/speechScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --speech (or
add --speech in an existing app) installs and wires this for you - see
@symbiote-native/cli.
expo-speech and expo-modules-core come along as regular dependencies, pinned to exact versions.
Never install either yourself, and never add the expo meta-package to your project (it bundles
its own Metro/Babel pipeline, which conflicts with this project’s own).
No permission, manifest edit or config plugin is involved.
All five adapters re-export the same functions; there is no per-adapter hook, composable or
service, since nothing here holds live state. speak returns at once and reports progress through
the onStart, onDone, onStopped and onError callbacks in its options.
import { useState } from 'react';import { speak, stop } from '@symbiote-native/speech/react';
export default function ReadAloud({ text }: { text: string }) { const [speaking, setSpeaking] = useState(false);
function onPress() { if (speaking) { void stop(); return; } speak(text, { language: 'en-US', onStart: () => setSpeaking(true), onDone: () => setSpeaking(false), onStopped: () => setSpeaking(false), }); }
return <button title={speaking ? 'Stop' : 'Read aloud'} onPress={onPress} />;}<script setup lang="ts">import { ref } from 'vue';import { speak, stop } from '@symbiote-native/speech/vue';
const props = defineProps<{ text: string }>();const speaking = ref(false);
function onPress() { if (speaking.value) { void stop(); return; } speak(props.text, { language: 'en-US', onStart: () => (speaking.value = true), onDone: () => (speaking.value = false), onStopped: () => (speaking.value = false), });}</script>
<template> <button :title="speaking ? 'Stop' : 'Read aloud'" @press="onPress" /></template>import { Component, input, signal } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { speak, stop } from '@symbiote-native/speech/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: `<button [title]="speaking() ? 'Stop' : 'Read aloud'" (press)="onPress()" />`,})export class ReadAloud { readonly text = input.required<string>(); readonly speaking = signal(false);
onPress(): void { if (this.speaking()) { void stop(); return; } speak(this.text(), { language: 'en-US', onStart: () => this.speaking.set(true), onDone: () => this.speaking.set(false), onStopped: () => this.speaking.set(false), }); }}There is no service to inject(): every function is a plain export off the core package.
<script lang="ts"> import { speak, stop } from '@symbiote-native/speech/svelte';
let { text }: { text: string } = $props(); let speaking = $state(false);
function onPress(): void { if (speaking) { void stop(); return; } speak(text, { language: 'en-US', onStart: () => (speaking = true), onDone: () => (speaking = false), onStopped: () => (speaking = false), }); }</script>
<button title={speaking ? 'Stop' : 'Read aloud'} onPress={onPress} />import { createSignal } from 'solid-js';import { speak, stop } from '@symbiote-native/speech/solid';
export function ReadAloud(props: { text: string }) { const [speaking, setSpeaking] = createSignal(false);
function onPress() { if (speaking()) { void stop(); return; } speak(props.text, { language: 'en-US', onStart: () => setSpeaking(true), onDone: () => setSpeaking(false), onStopped: () => setSpeaking(false), }); }
return <button title={speaking() ? 'Stop' : 'Read aloud'} onPress={onPress} />;}Pick a voice
Section titled “Pick a voice”import { getAvailableVoicesAsync, speak, VoiceQuality } from '@symbiote-native/speech';
const voices = await getAvailableVoicesAsync();const enhanced = voices.find( v => v.language === 'en-US' && v.quality === VoiceQuality.Enhanced,);speak('Hello world', { voice: enhanced?.identifier });Functions and constants
Section titled “Functions and constants”| Signature | Description |
|---|---|
speak(text, options?): void |
Speaks the text. If something is already speaking, the new utterance is queued |
stop(): Promise<void> |
Interrupts the current utterance and clears the whole queue |
pause(): Promise<void> |
Pauses speaking. iOS only; throws UnavailabilityError on Android |
resume(): Promise<void> |
Resumes after pause. iOS only; throws UnavailabilityError on Android |
isSpeakingAsync(): Promise<boolean> |
Whether the engine is speaking. true while paused too |
getAvailableVoicesAsync(): Promise<IVoice[]> |
Lists the voices installed on the device. Throws UnavailabilityError if unsupported |
maxSpeechInputLength: number |
Longest text one speak call accepts. Number.MAX_VALUE on iOS |
VoiceQuality |
Default and Enhanced, the values of IVoice.quality |
ISpeechOptions
Section titled “ISpeechOptions”| Field | Type | Description |
|---|---|---|
language |
string | undefined |
IETF BCP 47 language code, such as en-US |
voice |
string | undefined |
Identifier of a voice from getAvailableVoicesAsync |
pitch |
number | undefined |
Pitch multiplier. 1.0 is normal |
rate |
number | undefined |
Speed multiplier. 1.0 is normal |
volume |
number | undefined |
From 0.0 (muted) to 1.0 (maximum). Defaults to 1.0 |
useApplicationAudioSession |
boolean | undefined |
iOS only. false lets the system manage a separate audio session for speech |
onStart |
(() => void) | undefined |
Called when speaking begins |
onDone |
(() => void) | undefined |
Called when the utterance finishes |
onStopped |
(() => void) | undefined |
Called when speaking is cut off by stop() |
onError |
((error: Error) => void) | undefined |
Called when speaking fails |
onBoundary |
((event: INativeBoundaryEvent) => void) | null | undefined |
Called at word boundaries with charIndex and charLength |
IVoice
Section titled “IVoice”| Field | Type | Description |
|---|---|---|
identifier |
string |
Pass this as ISpeechOptions.voice |
name |
string |
Display name of the voice |
quality |
VoiceQuality |
Default or Enhanced |
language |
string |
The voice’s language as an IETF BCP 47 code |
speakqueues, it does not interrupt. To replace what is being said, callstop()first.- Long text can exceed the limit. Android rejects text longer than
maxSpeechInputLength; split a long article into sentences and queue them. pauseandresumeare iOS only. Guard them with a platform check, or catch theUnavailabilityError.- Web-only fields are dropped. Upstream’s
WebVoice,SpeechEventCallback,_voiceIndexandonMark/onPause/onResumebelong to its separate web implementation; this package wraps only the iOS and Android native modules. - Sound can only be verified on a device. The headless tests fake the native module, so they
prove the callback registry, event routing and the
UnavailabilityErrorbranches.
Common questions
Section titled “Common questions”There is no sound on iOS. First check that the device is not in silent mode: on a physical
iPhone speak is silent then. If other audio in your app changes the audio session, that can also
quiet speech.
The voice I picked is ignored, or the voice list is short. Take the voice identifier from
getAvailableVoicesAsync() and pass exactly that. Newer iOS versions can return fewer or older
voices than you expect, and the system voices installed differ by device.
Speech in a non-English language sounds wrong or does not play on Android. Set language to a
BCP 47 code (de-DE) and make sure the device has that voice installed in its text-to-speech
settings.
onDone never fires after I pause() and resume(). Pausing reports onStopped, and the
completion callback is not guaranteed afterwards on iOS. Track state yourself if you pause.
My text is cut off or rejected. Android rejects text longer than maxSpeechInputLength. Split
long text into sentences and queue them.
Sources: Expo docs: Speech, expo/expo#10827 playsInSilentModeIOS does not influence Speech.speak, expo/expo#24243 events like onDone do not fire on iOS, expo/expo#12654 pause emits stopped and resume never emits done, expo/expo#7260 Speech.speak not working for non-English on Android.
How the wrapper works
Section titled “How the wrapper works”expo-speech’s JS is hand-ported into this package’s core/, resolving ExpoSpeech through
expo-modules-core rather than the expo meta-package. The five adapter entry points are plain
re-exports of core (Angular stays a physical subpath for its separate ngc/AOT build). The
native code is never vendored: expo-modules-autolinking resolves it from node_modules (see
the native setup guide).