apphouse
Version:
Component library for React that uses observable state management and theme-able components.
309 lines (280 loc) • 8.73 kB
text/typescript
import { makeAutoObservable } from 'mobx';
import { List } from '../models/List';
import { IUtteranceOptions, Utterance } from './textToSpeech/Utterance';
import { getUniqueId } from '../utils/string/getUniqueId';
type TextToSpeechStatusType = 'speaking' | 'paused' | 'cancelled' | 'idle';
/**
* A class that represents a text to speech synthesizer
* It is a wrapper around the SpeechSynthesis class, that handles multiple utterances
*/
export class TextToSpeech {
synthesizer: SpeechSynthesis | null = null;
selectedVoiceIndex: number;
utterances: List<Utterance>;
currentUtterance: Utterance | null;
textToSpeak: string | null = null;
status: TextToSpeechStatusType;
constructor() {
this.selectedVoiceIndex = 0;
this.synthesizer = window.speechSynthesis;
this.utterances = new List<Utterance>();
this.currentUtterance = null;
this.textToSpeak = null;
this.status = 'idle';
const _cancel = this.cancel;
window.addEventListener('beforeunload', function () {
console.log('beforeunload');
// Stop speech synthesis when refreshing the window
_cancel();
});
makeAutoObservable(this);
}
get voiceList() {
return this.synthesizer
?.getVoices()
.filter(
(voice) =>
voice.lang.includes('pt') ||
voice.lang.includes('en') ||
voice.lang.includes('es')
);
}
get voices() {
const voices = this.voiceList;
return (
voices?.map((voice, index) => ({
label: `${voice.name} (${voice.lang}) `,
value: index,
details: voice
})) || []
);
}
setSelectedVoiceIndex = (voiceIndex: number) => {
this.selectedVoiceIndex = voiceIndex;
};
private init = () => {
if ('speechSynthesis' in window) {
this.synthesizer = window.speechSynthesis;
}
};
/**
* This method will immediately pause any utterances that are being spoken.
*/
pause = () => {
try {
if (this.synthesizer?.speaking) {
this.synthesizer?.pause();
this.setStatus('paused');
}
} catch (error) {
console.error(error);
}
};
/**
* This method will cause the browser to resume speaking an
* utterance that was previously paused.
*/
resume = () => {
if (this.synthesizer?.paused) {
try {
this.synthesizer.resume();
this.setStatus('speaking');
} catch (error) {
console.error(error);
}
}
};
/**
* This method will immediately stop any utterances
* that are being spoken
*/
cancel = () => {
try {
if (this.synthesizer) {
this.synthesizer?.cancel();
this.setStatus('cancelled');
}
} catch (error) {
console.error(error);
}
};
/**
* Helper method to set a status so the status is observable
* @param status the status to set
*/
private setStatus = (status: TextToSpeechStatusType) => {
console.log('setting speech synthesis status', status);
this.status = status;
};
/**
* Speaks the text passed in as an argument or the textToSpeak property.
* If there is a current utterance, it will be cancelled and a new one
* will be created. If it speaking, it will be ignored.
* If an id is passed, it will be used to identify the utterance and speak that utterance.
* @param text the text to speak
*/
speak = (text?: string, id?: string) => {
const textToSpeak = text || this.textToSpeak || '';
// first we check if the synthesizer is available
if (this.synthesizer === null) {
// if not, we try to initialize it
this.init();
}
// get the current utterance
const currentUtterance = this.currentUtterance;
// there is a current utterance
if (currentUtterance) {
if (currentUtterance.id === id) {
this.speakUtterance(currentUtterance, textToSpeak);
} else {
// if it is not the same, we cancel the current utterance
// and switch to the utterance with the utterance id
this.cancel();
if (id) {
this.speakUtteranceId(id, textToSpeak);
} else {
this.speakUtterance(currentUtterance, textToSpeak);
}
}
} else {
// there's no current utterance, let's create a new utterance
// and try speaking again
this.createUtterance(id, text);
this.speak(text, id);
}
};
private speakUtteranceId = (id: string, text: string) => {
// check if utterance exists
let utterance = this.utterances.get(id);
if (utterance) {
utterance.setText(text);
this.setCurrentUtterance(utterance);
} else {
utterance = this.createUtterance(id, text);
}
this.speakUtterance(utterance, text);
};
private speakUtterance = (utterance: Utterance, text: string) => {
utterance.setText(text);
const synth = this.synthesizer;
if (!synth) {
// if the synthesizer is not available, we try to initialize it
this.init();
// and try speaking again
this.speakUtterance(utterance, text);
} else {
if (synth.speaking) {
if (synth.paused) {
this.resume();
return;
} else {
// ignore as it is already speaking
return;
}
}
// let's cancel current speech synthesis
this.cancel();
// set the current utterance
const utteranceInstance = utterance.utterance;
utteranceInstance && synth.speak(utteranceInstance);
this.setStatus('speaking');
}
};
restart = (utteranceId: string) => {
this.synthesizer?.cancel();
const utterance = this.utterances.get(utteranceId);
if (utterance) {
utterance.restart();
this.speak(utterance.text, utteranceId);
}
};
setCurrentUtterance = (utterance: Utterance) => {
this.currentUtterance = utterance;
};
/**
* Method to initialize an utterance.
* If the utterance already exists it sets it as the current utterance.
* If it doesn't exist, it creates a new utterance and sets it as the current utterance.
* @param {string} id the id of the utterance, must be unique
*/
initUtterance = (id: string) => {
const utterance = this.utterances.get(id);
if (utterance) {
this.setCurrentUtterance(utterance);
} else {
this.createUtterance(id);
}
};
/**
* Method to set the rate of the utterance.
* When setting the rate, unfortunately, the speech synthesis API
* does not allow us to set the rate of an utterance on the fly. So we
* have to cancel the current utterance and create a new one with the new rate.
*/
setRate = (id: string, rate: number) => {
const wasPreviouslySpeaking = this.status === 'speaking';
// get the utterance
const utterance = this.utterances.get(id);
if (wasPreviouslySpeaking && utterance?.status === 'speaking') {
// cancel current utterance
this.cancel();
}
if (utterance) {
utterance.setRate(rate);
// if it was previously speaking, we speak again
if (wasPreviouslySpeaking) {
this.speakUtteranceId(id, utterance.unread);
}
}
};
/**
* A method that sets the voice of the speech synthesis.
* When setting a different voice, unfortunately, the speech synthesis API
* does not allow us to set the voice of an utterance on the fly. So we
* have to cancel the current utterance and create a new one with the new
* voice.
*/
setVoice = (id: string, voice: SpeechSynthesisVoice) => {
const wasPreviouslySpeaking = this.status === 'speaking';
// get the utterance
const utterance = this.utterances.get(id);
if (wasPreviouslySpeaking && utterance?.status === 'speaking') {
// cancel current utterance
this.cancel();
}
if (utterance) {
utterance.setVoice(voice);
// if it was previously speaking, we speak again
if (wasPreviouslySpeaking) {
this.speakUtteranceId(id, utterance.unread);
}
}
};
/**
* Method to create a new utterance and add it to the list of utterances.
* In addition, it also sets the current utterance to the newly created one.
* It is used privately
* @param id
* @param text
* @param options
* @returns
*/
private createUtterance = (
id?: string,
text?: string,
options?: IUtteranceOptions
): Utterance => {
const utterance = new Utterance(
id || getUniqueId(),
text || '',
options || {}
);
this.utterances.set(utterance);
this.setCurrentUtterance(utterance);
return utterance;
};
setTextToSpeak = (text: string) => {
this.textToSpeak = text;
};
}
export const textToSpeech = new TextToSpeech();