@elibrary-inno/bookreader
Version:
The Internet Archive BookReader.
273 lines (239 loc) • 9.01 kB
JavaScript
import PageChunkIterator from './PageChunkIterator.js';
/** @typedef {import('./utils.js').ISO6391} ISO6391 */
/** @typedef {import('./PageChunk.js')} PageChunk */
/**
* @export
* @typedef {Object} TTSEngineOptions
* @property {String} server
* @property {String} bookPath
* @property {ISO6391} bookLanguage
* @property {Function} onLoadingStart
* @property {Function} onLoadingComplete
* @property {Function} onDone called when the entire book is done
* @property {function(PageChunk): PromiseLike} beforeChunkPlay will delay the playing of the chunk
* @property {function(PageChunk): void} afterChunkPlay fires once a chunk has fully finished
*/
/**
* @typedef {Object} AbstractTTSSound
* @property {PageChunk} chunk
* @property {boolean} loaded
* @property {number} rate
* @property {SpeechSynthesisVoice} voice
* @property {(callback: Function) => void} load
* @property {() => PromiseLike} play
* @property {() => Promise} stop
* @property {() => void} pause
* @property {() => void} resume
* @property {() => void} finish force the sound to 'finish'
* @property {number => void} setPlaybackRate
* @property {SpeechSynthesisVoice => void} setVoice
**/
/** Handling bookreader's text-to-speech */
export default class AbstractTTSEngine {
/**
* @protected
* @param {TTSEngineOptions} options
*/
constructor(options) {
this.playing = false;
this.paused = false;
this.opts = options;
/** @type {PageChunkIterator} */
this._chunkIterator = null;
/** @type {AbstractTTSSound} */
this.activeSound = null;
this.playbackRate = 1;
/** Events we can bind to */
this.events = $({});
/** @type {SpeechSynthesisVoice} */
this.voice = null;
// Listen for voice changes (fired by subclasses)
this.events.on('voiceschanged', this.updateBestVoice);
this.events.trigger('voiceschanged');
}
/**
* @abstract
* @return {boolean}
*/
static isSupported() { throw new Error("Unimplemented abstract class"); }
/**
* @abstract
* @return {SpeechSynthesisVoice[]}
*/
getVoices() { throw new Error("Unimplemented abstract class"); }
/** @abstract */
init() { return null; }
updateBestVoice = () => {
this.voice = AbstractTTSEngine.getBestBookVoice(this.getVoices(), this.opts.bookLanguage);
}
/**
* @param {number} leafIndex
* @param {number} numLeafs total number of leafs in the current book
*/
start(leafIndex, numLeafs) {
this.playing = true;
this.paused = false;
this.opts.onLoadingStart();
this._chunkIterator = new PageChunkIterator(numLeafs, leafIndex, {
server: this.opts.server,
bookPath: this.opts.bookPath,
pageBufferSize: 5,
});
this.step();
this.events.trigger('start');
}
stop() {
if (this.activeSound) this.activeSound.stop();
this.playing = false;
this.paused = true;
this._chunkIterator = null;
this.activeSound = null;
this.events.trigger('stop');
}
/** @public */
pause() {
const fireEvent = !this.paused && this.activeSound;
this.paused = true;
if (this.activeSound) this.activeSound.pause();
if (fireEvent) this.events.trigger('pause');
}
/** @public */
resume() {
const fireEvent = this.paused && this.activeSound;
this.paused = false;
if (this.activeSound) this.activeSound.resume();
if (fireEvent) this.events.trigger('resume');
}
togglePlayPause() {
if (this.paused) this.resume();
else this.pause();
}
/** @public */
jumpForward() {
if (this.activeSound) this.activeSound.finish();
}
/** @public */
async jumpBackward() {
await Promise.all([
this.activeSound.stop(),
this._chunkIterator.decrement()
.then(() => this._chunkIterator.decrement())
]);
this.step();
}
/** @param {string} voiceURI */
setVoice(voiceURI) {
// if the user actively selects a voice, don't re-choose best voice anymore
// MS Edge fires voices changed randomly very often
this.events.off('voiceschanged', this.updateBestVoice);
this.voice = this.getVoices().find(voice => voice.voiceURI === voiceURI);
// if the current book has a language set, store the selected voice with the book language as a suffix
if (this.opts.bookLanguage) {
localStorage.setItem(`BRtts-voice-${this.opts.bookLanguage}`, this.voice.voiceURI);
}
if (this.activeSound) this.activeSound.setVoice(this.voice);
}
/** @param {number} newRate */
setPlaybackRate(newRate) {
this.playbackRate = newRate;
if (this.activeSound) this.activeSound.setPlaybackRate(newRate);
}
/** @private */
async step() {
const chunk = await this._chunkIterator.next();
if (chunk == PageChunkIterator.AT_END) {
this.stop();
this.opts.onDone();
return;
}
this.opts.onLoadingStart();
const sound = this.createSound(chunk);
sound.chunk = chunk;
sound.rate = this.playbackRate;
sound.voice = this.voice;
sound.load(() => this.opts.onLoadingComplete());
this.opts.onLoadingComplete();
await this.opts.beforeChunkPlay(chunk);
if (!this.playing) return;
const playPromise = await this.playSound(sound)
.then(()=> this.opts.afterChunkPlay(sound.chunk));
if (this.paused) this.pause();
await playPromise;
if (this.playing) return this.step();
}
/**
* @abstract
* @param {PageChunk} chunk
* @return {AbstractTTSSound}
*/
createSound(chunk) { throw new Error("Unimplemented abstract class"); }
/**
* @param {AbstractTTSSound} sound
* @return {PromiseLike} promise called once playing finished
*/
playSound(sound) {
this.activeSound = sound;
if (!this.activeSound.loaded) this.opts.onLoadingStart();
return this.activeSound.play();
}
/** Convenience wrapper for {@see AbstractTTSEngine.getBestVoice} */
getBestVoice() {
return AbstractTTSEngine.getBestBookVoice(this.getVoices(), this.opts.bookLanguage);
}
/**
* @private
* Find the best voice to use given the available voices, the book language, and the user's
* languages.
* @param {SpeechSynthesisVoice[]} voices
* @param {ISO6391} bookLanguage
* @param {string[]} userLanguages languages in BCP47 format (e.g. en-US). Ordered by preference.
* @return {SpeechSynthesisVoice | undefined}
*/
static getBestBookVoice(voices, bookLanguage, userLanguages = navigator.languages) {
const bookLangVoices = voices.filter(v => v.lang.startsWith(bookLanguage));
// navigator.languages browser support isn't great yet, so get just 1 language otherwise
// Sample navigator.languages: ["en-CA", "fr-CA", "fr", "en-US", "en", "de-DE", "de"]
userLanguages = userLanguages || (navigator.language ? [navigator.language] : []);
// user languages that match the book language
const matchingUserLangs = userLanguages.filter(lang => lang.startsWith(bookLanguage));
// First try to find the last chosen voice from localStorage for the current book language
return AbstractTTSEngine.getMatchingStoredVoice(bookLangVoices, bookLanguage)
// Try to find voices that intersect these two sets
|| AbstractTTSEngine.getMatchingVoice(matchingUserLangs, bookLangVoices)
// no user languages match the books; let's return the best voice for the book language
|| (bookLangVoices.find(v => v.default) || bookLangVoices[0])
// No voices match the book language? let's find a voice in the user's language
// and ignore book lang
|| AbstractTTSEngine.getMatchingVoice(userLanguages, voices)
// C'mon! Ok, just read with whatever we got!
|| (voices.find(v => v.default) || voices[0]);
}
/**
* @private
* Get the voice last selected by the user for the book language from localStorage.
* Returns undefined if no voice is stored or found.
* @param {SpeechSynthesisVoice[]} voices browser voices to choose from
* @param {ISO6391} bookLanguage book language to look for
* @return {SpeechSynthesisVoice | undefined}
*/
static getMatchingStoredVoice(voices, bookLanguage) {
const storedVoice = localStorage.getItem(`BRtts-voice-${bookLanguage}`);
return (storedVoice ? voices.find(v => v.voiceURI === storedVoice) : undefined);
}
/**
* @private
* Get the best voice that matches one of the BCP47 languages (order by preference)
* @param {string[]} languages in BCP 47 format (e.g. 'en-US', or 'en'); ordered by preference
* @param {SpeechSynthesisVoice[]} voices voices to choose from
* @return {SpeechSynthesisVoice | undefined}
*/
static getMatchingVoice(languages, voices) {
for (const lang of languages) {
// Chrome Android was returning voice languages like `en_US` instead of `en-US`
const matchingVoices = voices.filter(v => v.lang.replace('_', '-').startsWith(lang));
if (matchingVoices.length) {
return matchingVoices.find(v => v.default) || matchingVoices[0];
}
}
}
}