expo-speech
Version:
Provides text-to-speech functionality.
127 lines (112 loc) • 4.24 kB
text/typescript
import { CodedError, NativeModule, registerWebModule } from 'expo-modules-core';
import { SpeechOptions, WebVoice, VoiceQuality } from './Speech.types';
//https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance/text
const MAX_SPEECH_INPUT_LENGTH = 32767;
async function getVoices(): Promise<SpeechSynthesisVoice[]> {
return new Promise<SpeechSynthesisVoice[]>((resolve) => {
const voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
resolve(voices);
return;
}
// when a page loads it takes some amount of time to populate the voices list
// see https://stackoverflow.com/a/52005323/4337317
window.speechSynthesis.onvoiceschanged = function () {
const voices = window.speechSynthesis.getVoices();
resolve(voices);
};
});
}
type ExpoSpeechEvents = {
'Exponent.speakingStarted': (params: { id: string; nativeEvent: SpeechSynthesisEvent }) => void;
'Exponent.speakingDone': (params: { id: string; nativeEvent: SpeechSynthesisEvent }) => void;
'Exponent.speakingStopped': (params: { id: string; nativeEvent: SpeechSynthesisEvent }) => void;
'Exponent.speakingError': (params: { id: string; nativeEvent: SpeechSynthesisEvent }) => void;
};
class ExpoSpeech extends NativeModule<ExpoSpeechEvents> {
async speak(id: string, text: string, options: SpeechOptions): Promise<SpeechSynthesisUtterance> {
if (text.length > MAX_SPEECH_INPUT_LENGTH) {
throw new CodedError(
'ERR_SPEECH_INPUT_LENGTH',
'Speech input text is too long! Limit of input length is: ' + MAX_SPEECH_INPUT_LENGTH
);
}
const message = new SpeechSynthesisUtterance();
if (typeof options.rate === 'number') {
message.rate = options.rate;
}
if (typeof options.pitch === 'number') {
message.pitch = options.pitch;
}
if (typeof options.language === 'string') {
message.lang = options.language;
}
if (typeof options.volume === 'number') {
message.volume = options.volume;
}
if ('_voiceIndex' in options && options._voiceIndex != null) {
const voices = await getVoices();
message.voice = voices[Math.min(voices.length - 1, Math.max(0, options._voiceIndex))];
}
if (typeof options.voice === 'string') {
const voices = await getVoices();
message.voice =
voices[
Math.max(
0,
voices.findIndex((voice) => voice.voiceURI === options.voice)
)
];
}
if (typeof options.onResume === 'function') {
message.onresume = options.onResume;
}
if (typeof options.onMark === 'function') {
message.onmark = options.onMark;
}
if (typeof options.onBoundary === 'function') {
message.onboundary = options.onBoundary;
}
message.onstart = (nativeEvent: SpeechSynthesisEvent) => {
this.emit('Exponent.speakingStarted', { id, nativeEvent });
};
message.onend = (nativeEvent: SpeechSynthesisEvent) => {
this.emit('Exponent.speakingDone', { id, nativeEvent });
};
message.onpause = (nativeEvent: SpeechSynthesisEvent) => {
this.emit('Exponent.speakingStopped', { id, nativeEvent });
};
message.onerror = (nativeEvent: SpeechSynthesisErrorEvent) => {
this.emit('Exponent.speakingError', { id, nativeEvent });
};
message.text = text;
window.speechSynthesis.speak(message);
return message;
}
async getVoices(): Promise<WebVoice[]> {
const voices = await getVoices();
return voices.map((voice) => ({
identifier: voice.voiceURI,
quality: VoiceQuality.Default,
isDefault: voice.default,
language: voice.lang,
localService: voice.localService,
name: voice.name,
voiceURI: voice.voiceURI,
}));
}
async isSpeaking(): Promise<boolean> {
return window.speechSynthesis.speaking;
}
async stop(): Promise<void> {
return window.speechSynthesis.cancel();
}
async pause(): Promise<void> {
return window.speechSynthesis.pause();
}
async resume(): Promise<void> {
return window.speechSynthesis.resume();
}
maxSpeechInputLength = MAX_SPEECH_INPUT_LENGTH;
}
export default registerWebModule(ExpoSpeech, 'ExpoSpeech');