ttsreader
Version:
Text to Speech wrapper, player and helpers for the web-speech-api speech synthesis
384 lines (320 loc) • 12.3 kB
JavaScript
function codeToLanguageCodeOnly (code) {
if (code == null || code.length < 2) {
return "";
}
return code.toLowerCase().split("-")[0].split("_")[0];
}
function doCodesShareLanguage (a,b) {
return codeToLanguageCodeOnly(a) == codeToLanguageCodeOnly(b);
}
exports.TtsEngine = {
DEFAULT_LANG: "en",
voice: {},
voices: [],
rate: 1,
utteranceId: 0,
startedAndNotTerminatedCounter: 0,
listener: null, // includes: {onInit, onStart, onDone}
utterance: {},
_googleBugTimeout: null,
_speakTimeout: null,
_canceledAtMs: 0,
init: function (listener) {
if (listener) {
this.listener = listener;
}
this._populateVoices();
speechSynthesis.onvoiceschanged = () => { this._populateVoices(); };
},
setListener: function (listener) {
this.listener = listener;
},
/// Assumes voices was populated.
/// If voice, voiceURI, lang were not available, then it checks whether the current voice is available to keep.
/// If current voice is available it is kept. Otherwise, the first voice in list is selected.
/// NOTE: 'lang' is only lang, no 'locale' - ie no accent
setBestMatchingVoice: function(voice, voiceURI, lang) {
if (this.voices == null || this.voices.length == 0) {
return "";
}
if ((!voice || !voice.voiceURI) && !voiceURI && !lang) {
if (this.voice && this.voice.voiceURI) {
voiceURI = this.voice.voiceURI;
} else {
lang = this.DEFAULT_LANG;
}
}
if (voice) {
voiceURI = voice.voiceURI || voiceURI;
}
if (voiceURI) {
for (const iVoice of this.voices) {
if (iVoice.voiceURI == voiceURI) {
this.voice = iVoice;
return iVoice.voiceURI;
}
}
}
if (lang) {
// If current voice already has the looked for lang, do nothing:
if (this.voice && doCodesShareLanguage(this.voice.lang, lang)) {
return this.voice.voiceURI;
}
let filteredVoices = this.voices.filter((iVoice)=>{
return doCodesShareLanguage(iVoice.lang, lang);
});
if (filteredVoices && filteredVoices.length>0) {
if (filteredVoices.length==1) {
this.voice = filteredVoices[0];
return this.voice.voiceURI;
} else if (!lang.startsWith("en") && !lang.startsWith("es")) {
this.voice = filteredVoices[0];
return this.voice.voiceURI;
} else {
// Now - within those voices - we prefer 'en', 'en-GB', 'en-UK', 'en-US' if lang is en. 'es-ES' if lang is 'es':
// local = 1.5 points;
// no accent = 4 points; -> tops all combos but local good accent.
// good accent = 3 points;
// neutral accent = 2 points; -> local tops remote good accent
// no score accents = 0 points;
let selectedVoiceScore = -1;
let selectedVoice;
for (const iVoice of filteredVoices) {
let score = 0;
if (iVoice.localService) {
score += 1.5;
}
if (iVoice.lang.length == 2) {
score += 3;
} else if (["en-us","en-uk","en-gb","es-es"].indexOf(iVoice.lang.toLowerCase().replace("_","-"))!=-1) {
score += 4;
} else if (["en-in"].indexOf(iVoice.lang.toLowerCase().replace("_","-"))==-1) {
score += 2;
}
console.log('score: ' + score + ' for: ', iVoice);
if (score>selectedVoiceScore) {
selectedVoiceScore = score;
selectedVoice = iVoice;
}
}
if (selectedVoice) {
this.voice = selectedVoice;
return this.voice.voiceURI;
}
}
}
}
for (const iVoice of this.voices) {
this.voice = iVoice;
if (iVoice.localService) {
return iVoice.voiceURI;
}
}
return this.voice.voiceURI;
},
_populateVoices: function () {
let voices = window.speechSynthesis.getVoices();
if (voices && voices.length>0) {
this.voices = voices.filter((voice)=>{
if (!voice.voiceURI.includes("com.apple.eloquence") && !voice.voiceURI.includes("com.apple.speech.synthesis")) {
return voice;
}
});
this.setBestMatchingVoice(this.voice, null, null);
if (this.listener && this.listener.onInit) {
this.listener.onInit(this.voices);
}
}
},
setVoiceByUri: function (voiceURI) {
this.setBestMatchingVoice(null, voiceURI, null);
},
getVoiceURI: function () {
if (!this.voice) {
this.setBestMatchingVoice();
}
if (this.voice) {
return this.voice.voiceURI;
}
return "";
},
setRate: function (rate) {
if (typeof rate == 'string') {
rate = Number(rate);
}
if (isNaN(rate)) {
return;
}
if (rate<0.1) {
rate = 0.1;
}
if (rate>4) {
rate = 4;
}
this.rate = rate;
},
isInitiated: function() {
return this.voices!=null;
},
_defaultOnStart: function(ev) {
//console.log("_defaultOnStart utterance ", ev);
this.startedAndNotTerminatedCounter++;
this._solveChromeBug();
},
_defaultOnEnd: function(ev) {
//console.log("_defaultOnEnd utterance ", ev);
if (this.startedAndNotTerminatedCounter>0) {
this.startedAndNotTerminatedCounter--;
}
this._clearUtteranceTimeouts();
},
_defaultOnError: function(ev) {
//console.log("_defaultOnError utterance ", ev);
if (this.startedAndNotTerminatedCounter>0) {
this.startedAndNotTerminatedCounter--;
}
this._clearUtteranceTimeouts();
},
_clearUtteranceTimeouts: function() {
if (this._googleBugTimeout != null) {
window.clearTimeout(this._googleBugTimeout);
this._googleBugTimeout = null;
}
},
_solveChromeBug: function() {
if (!this.voice) {
return;
}
if (this.voice.voiceURI.toLowerCase().indexOf("google") === -1) {
return;
}
// pause & resume every few secs:
this._clearUtteranceTimeouts();
let self = this;
this._googleBugTimeout = window.setTimeout(function () {
window.speechSynthesis.pause();
window.speechSynthesis.resume();
self._solveChromeBug();
}, 10000);
},
_prepareTextForSynthesis: function (text) {
let decodedText = text;
decodedText = decodedText.replace("·", ", ");
decodedText = decodedText.replace("- ", ", ");
decodedText.trim();
return decodedText;
},
speakOut: function (text) {
let instance = this;
if (this.startedAndNotTerminatedCounter>0 || window.speechSynthesis.paused || window.speechSynthesis.pending || window.speechSynthesis.speaking) {
console.log('tts - ronen1')
this.stop();
this._speakTimeout = window.setTimeout(function (){
instance.speakOut(text);
}, 200);
return;
}
if (!text) {
if (this.utterance) {
this.utterance.onend();
}
return;
}
text = this._prepareTextForSynthesis(text);
if (!this.isInitiated()) {
return false;
}
this.utteranceId++;
let utterance = new SpeechSynthesisUtterance();
this.utterance = utterance;
utterance.text = text;
if (this.voice==null) {
this.setBestMatchingVoice(null, null, null);
}
//console.log('voice is: ', this.voice);
if (this.voice) {
utterance.lang = this.voice.lang;
utterance.voiceURI = this.voice.voiceURI; // For a bug in Chrome on Android.
utterance.voice = this.voice;
}
utterance.rate = this.rate;
let self = this;
utterance.onmark = function (ev) {
console.log('onmark ', ev);
}
utterance.onstart = function (ev) {
console.log('onstart ', ev);
self._defaultOnStart(ev);
if (self.listener && self.listener.onStart) {
self.listener.onStart();
}
};
utterance.onboundary = function(event) {
// TODO: use this to mark specific word.
console.log('onboundary: ' + event.name + ' boundary reached after ' + event.elapsedTime + ' milliseconds.', event);
// event looks like:
/*
* bubbles: false
cancelBubble: false
cancelable: false
charIndex: 0
charLength: 1
composed: false
currentTarget: SpeechSynthesisUtterance {voiceURI: "Alex", text: "123456789121111 e.g. hi i am john and this is a ra…to type depending on youre your highest WPM rank ", lang: "en-US", voice: SpeechSynthesisVoice, volume: -1, …}
defaultPrevented: false
elapsedTime: 176.75999450683594
eventPhase: 0
isTrusted: true
name: "word"
path: []
returnValue: true
srcElement: SpeechSynthesisUtterance {voiceURI: "Alex", text: "123456789121111 e.g. hi i am john and this is a ra…to type depending on youre your highest WPM rank ", lang: "en-US", voice: SpeechSynthesisVoice, volume: -1, …}
target: SpeechSynthesisUtterance {voiceURI: "Alex", text: "123456789121111 e.g. hi i am john and this is a ra…to type depending on youre your highest WPM rank ", lang: "en-US", voice: SpeechSynthesisVoice, volume: -1, …}
timeStamp: 24511.29999998375
type: "boundary"
utterance: SpeechSynthesisUtterance {voiceURI: "Alex", text: "1234567891211... }
* */
}
utterance.onend = function (ev) {
//console.log('end');
self._defaultOnEnd(ev);
if (self.listener && self.listener.onDone) {
self.listener.onDone();
}
utterance = null;
};
utterance.onerror = function (ev) {
//console.log('error ', ev);
self._defaultOnError(ev);
utterance = null;
};
console.log('tts - ronen right away')
this._speakUtterance(utterance);
},
stop() {
if (this._speakTimeout != null) {
window.clearTimeout(this._speakTimeout);
this._speakTimeout = null;
}
window.speechSynthesis.cancel();
this.startedAndNotTerminatedCounter = 0;
this._canceledAtMs = Date.now();
},
_speakUtterance(utterance) {
if (this._speakTimeout != null) {
window.clearTimeout(this._speakTimeout);
this._speakTimeout = null;
}
if (window.speechSynthesis.paused) {
window.speechSynthesis.resume();
}
//console.log("utterance = ", utterance);
if (Date.now()-this._canceledAtMs > 100) {
window.speechSynthesis.speak(utterance);
} else {
this._speakTimeout = window.setTimeout(function (){
window.speechSynthesis.speak(utterance);
}, 200);
}
}
};