ttsreader
Version:
Text to Speech wrapper, player and helpers for the web-speech-api speech synthesis
1,445 lines (1,293 loc) โข 72.7 kB
JavaScript
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
window.wsGlobals = window.wsGlobals || {};
window.wsGlobals.TtsEngine = require("./index").TtsEngine;
},{"./index":5}],2:[function(require,module,exports){
const { ServerVoices } = require("./serverVoices");
const SERVER_TTS_ENDPOINT_PRODUCTION = "https://us-central1-ttsreader.cloudfunctions.net/tts";
const SERVER_TTS_ENDPOINT_LOCAL = "http://127.0.0.1:5001/ttsreader/us-central1/tts";
// Set to true for local server:
const shouldUseLocalWhenInLocalhost = false;
const isDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
const SERVER_TTS_ENDPOINT = (isDev && shouldUseLocalWhenInLocalhost) ? SERVER_TTS_ENDPOINT_LOCAL : SERVER_TTS_ENDPOINT_PRODUCTION;
window.SERVER_TTS_ENDPOINT = SERVER_TTS_ENDPOINT;
class ServerTts {
static voices = ServerVoices.voices;
static buffer = [];
static currentAudio = null;
static listener = {
onInit: (voices) => { console.log('onInit ', voices); },
onStart: (id) => { console.log('onStart ', id); },
onDone: (id) => { console.log('onDone ', id); },
onError: (id, error) => { console.log('onError ', id, error); },
onReady: (id) => { console.log('onReady ', id); }, // โ
new callback
};
static getVoices() {
return ServerTts.voices;
}
static init(listener) {
if (listener) ServerTts.listener = listener;
ServerTts.listener.onInit(ServerTts.voices);
}
// We got here - only if not in buffer, or has error.
static async bufferNewUtterance (
text, voiceURI, langBCP47, rate, id, authToken, onSuccess, onError, isTest = false,
tryCount = 0 ) {
let utterance = { text, voiceURI, langBCP47, rate, id, wasPlayed: false, audio: null, renderStatus: "waiting", onSuccess, onError };
console.log('Buffering: ', utterance.id);
tryCount = tryCount || 0;
async function tryAgain(waitInMs, maxTries) {
// Wait for waitInMs[ms] and try again:
await new Promise(resolve => setTimeout(resolve, waitInMs));
if (tryCount < maxTries) {
console.log(`Retrying to generate audio for ${utterance.id}, attempt ${tryCount + 1}`);
ServerTts.bufferNewUtterance(text, voiceURI, langBCP47, rate, id, authToken, onSuccess, onError, isTest, tryCount + 1);
} else {
console.error(`Failed to generate audio for ${utterance.id} after 3 attempts`);
// Take it out of the buffer, as it failed to generate:
ServerTts.buffer = ServerTts.buffer.filter(u => u.id !== id);
onError("Failed buffering ", utterance.id);
}
}
// Make sure it's not already in the buffer, as we don't want duplicates, errors, etc.
ServerTts.buffer = ServerTts.buffer.filter(u => u.id !== id);
ServerTts.buffer.push(utterance); // So we know it's in the process of being generated.
// If not in buffer, generate audio - and if successful, add it to the buffer.
// If audio generation fails, for a reason other than 429 - try again after 500ms, up to 3 times.
// If it fails with 429 - it means user's quota reached.
// Now - let's generate the audio:
console.log('generating audio for: ', utterance);
fetch(SERVER_TTS_ENDPOINT, {
method: "POST",
headers: {
"Authorization": `Bearer ${authToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
text: text,
lang: langBCP47,
voice: voiceURI,
rate: utterance.rate >= 0.95 ? 1 : utterance.rate,
isTest: Boolean(isTest)
})
})
.then(response => {
if (response.ok) {
return response.blob();
} else {
if (response.status === 429) {
// Take it out of the buffer, as it failed to generate:
ServerTts.buffer = ServerTts.buffer.filter(u => u.id !== id);
// User reached his quota, notify the listener:
onError(429);
} else {
tryAgain(500, 3); // Retry after 500ms, up to 3 times
}
return Promise.reject(); // Stop the chain
}
})
.then(blob => {
console.log("Got AUDIO for: ", utterance.text);
const url = URL.createObjectURL(blob);
utterance.audio = (new Audio(url));
utterance.blob = blob; // ๐ this line is here to prevent G.C. from cleaning the blob
utterance.audio.playbackRate = utterance.rate >= 0.95 ? utterance.rate : 1,
utterance.renderStatus = "done"; // Mark as done
utterance.onSuccess(); // โ
Notify that audio is ready
try {
// While we're at it, let's remove the first utterances so that we don't memory leak:
while ((ServerTts.buffer.length > 50 && ServerTts.buffer[0].wasPlayed) || ServerTts.buffer.length > 50) {
ServerTts.buffer.shift();
}
} catch (e) {
// Do nothing, as we'll just keep it in the buffer.
}
})
.catch(err => {
tryAgain(500, 3); // Retry after 500ms, up to 3 times
});
}
// Send the blob onSuccess. No need to buffer it.
static async generateAudioSync(text, voiceURI, langBCP47, rate, id, authToken, onSuccess, onError, optionalParamsAsJson) {
let utterance = { text, voiceURI, langBCP47, rate, id, wasPlayed: false, audio: null };
console.log('Generating: ', utterance.id);
let quality = "48khz_192kbps"; // default
if (optionalParamsAsJson && optionalParamsAsJson.quality) {
quality = optionalParamsAsJson.quality;
}
try {
const response = await fetch(SERVER_TTS_ENDPOINT, {
method: "POST",
headers: {
"Authorization": `Bearer ${authToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
text: text,
lang: langBCP47,
voice: voiceURI,
rate: rate,
quality: quality
})
});
if (!response.ok) {
throw new Error(`Server returned status ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// โ
Notify that audio is ready
onSuccess(url); // Sends the blob URL, where the audio is stored. Client can take
// it from there to Audio element: audio.src = url; or new Audio(url);
/* TODO: Downloadable wavs should be handled as follows here in the comment:
const audioElement = new Audio(url);
const audioURL = url;
if (audioURL) {
const link = document.createElement('a');
link.href = audioURL;
link.download = 'longer-test-4-steve.wav';
document.body.appendChild(link);
link.click();
// Clean up
document.body.removeChild(link);
}*/
} catch (error) {
onError(error.message);
return;
}
}
static async speak(id, listener) {
ServerTts.stop(); // Safe to run even if not playing.
console.log('Got speak request, id: ', id);
const getUtterance = () => ServerTts.buffer.find(u => u.id === id);
let utterance = getUtterance();
if (!utterance) return ServerTts.listener.onError(id, "Utterance not found in buffer");
let waited = 0;
while (!utterance.audio && waited < 10000) {
await new Promise(r => setTimeout(r, 200));
waited += 200;
utterance = getUtterance(); // refresh in case it got updated
}
if (!utterance.audio) {
listener.onError(id, "Audio not available after 10s");
return;
}
ServerTts.currentAudio = utterance.audio;
ServerTts.currentAudio.onended = () => {
utterance.wasPlayed = true;
listener.onDone(id);
};
ServerTts.currentAudio.onerror = () => {
listener.onError(id, "Audio playback failed");
listener.onDone(id);
};
listener.onStart(id);
ServerTts.currentAudio.play().catch(err => {
listener.onError(id, err.message);
utterance.wasPlayed = true;
listener.onDone(id);
});
}
static stop() {
console.log('Got stop request, current audio: ', ServerTts.currentAudio);
if (ServerTts.currentAudio) {
ServerTts.currentAudio.pause();
ServerTts.currentAudio.currentTime = 0;
}
if (ServerTts.buffer && ServerTts.buffer.length > 0) {
// Iterate through the buffer and set all onSuccess to () => {} to avoid unintended playbacks.
ServerTts.buffer.forEach(u => {
if (u.onSuccess) {
u.onSuccess = () => {}; // Clear the onSuccess callback to avoid unintended playbacks.
}
});
}
}
}
if (typeof module != 'undefined') {
module.exports = { ServerTts };
}
},{"./serverVoices":3}],3:[function(require,module,exports){
// More voices can be selected here from GCP: https://cloud.google.com/text-to-speech/docs/list-voices-and-types
// Here from MS: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts
// MS voice gallery: https://speech.microsoft.com/portal/47e652ae62044c38a3964f2914437ad2/voicegallery
/*Aria.mp3
Christopher.mp3
Eric.mp3
Jenny.mp3
Libby.mp3
Lily.mp3
Mark.mp3
Michelle.mp3
Noah.mp3
Olivia.mp3
Ryan.mp3*/
class ServerVoices {
static voices = [
{
voiceURI: "ttsreaderServer.azure.en-GB-OllieMultilingualNeural",
name: "Ollie",
lang: "en-GB",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.en-GB-SoniaNeural",
name: "Sonia",
lang: "en-GB",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.en-GB-AbbiNeural",
name: "Abbi",
lang: "en-GB",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.es-ES-SaulNeural",
name: "Saul",
lang: "es-ES",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.es-ES-VeraNeural",
name: "Vera",
lang: "es-ES",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.es-ES-AlvaroNeural",
name: "Alvaro",
lang: "es-ES",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.es-ES-ElviraNeural",
name: "Elvira",
lang: "es-ES",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.it-IT-MarcelloMultilingualNeural",
name: "Marcello Premium",
lang: "it-IT",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.it-IT-IsabellaNeural",
name: "Isabella Premium",
lang: "it-IT",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.hi-IN-ArjunNeural",
name: "Arjun Premium",
lang: "hi-IN",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.hi-IN-AartiNeural",
name: "Aarti Premium",
lang: "hi-IN",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.ar-EG-SalmaNeural",
name: "Salma Premium",
lang: "ar-EG",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.ar-EG-ShakirNeural",
name: "Shakir Premium",
lang: "ar-EG",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.en-US-AriaNeural",
name: "Aria Premium",
lang: "en-US",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.en-US-NovaTurboMultilingualNeural",
name: "Nova Premium",
lang: "en-US",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.en-US-AdamMultilingualNeural",
name: "Adam Premium",
lang: "en-US",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.core1.f1",
name: "ื ืขืื ืืืฉ ื ืกืืื ื",
lang: "he-IL",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.core1.f3",
name: "ืจืื ืืืฉ ื ืกืืื ื",
lang: "he-IL",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.core1.f2",
name: "ืืกืชืจ ืืืฉ ื ืกืืื ื",
lang: "he-IL",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.core1.m1",
name: "ืืืื ืืืฉ ื ืกืืื ื",
lang: "he-IL",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.fr-FR-VivienneMultilingualNeural",
name: "Vivienne Premium",
lang: "fr-FR",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.fr-FR-HenriNeural",
name: "Henri Premium",
lang: "fr-FR",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.de-DE-ConradNeural",
name: "Conrad Premium",
lang: "de-DE",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.de-DE-SeraphinaMultilingualNeural",
name: "Seraphina Premium",
lang: "de-DE",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.en-GB-AdaMultilingualNeural",
name: "Ada Premium",
lang: "en-GB",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
},
{
voiceURI: "ttsreaderServer.azure.he-IL-AvriNeural",
name: "ืืืจื",
lang: "he-IL",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
},
{
voiceURI: "ttsreaderServer.azure.he-IL-HilaNeural",
name: "ืืืื",
lang: "he-IL",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
avatar: "/images/avatars/ttsreaderServer.azure.he-IL-HilaNeural.webp",
demo: "/audio/ttsreaderServer.azure.he-IL-HilaNeural.mp3",
},
{
voiceURI: "ttsreaderServer.azure.es-MX-JorgeNeural",
name: "Jorge Premium",
lang: "es-MX",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
avatar: "/images/avatars/ttsreaderServer.azure.es-MX-JorgeNeural.webp",
demo: "/audio/ttsreaderServer.azure.es-MX-JorgeNeural.mp3",
},
{
voiceURI: "ttsreaderServer.azure.es-MX-DaliaNeural",
name: "Dalia Premium",
lang: "es-MX",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
avatar: "/images/avatars/ttsreaderServer.azure.es-MX-DaliaNeural.webp",
demo: "/audio/ttsreaderServer.azure.es-MX-DaliaNeural.mp3",
},
{
voiceURI: "ttsreaderServer.gcp.en-GB-Standard-A",
name: "Olivia Premium",
lang: "en-GB",
localService: false,
default: true,
premiumLevel: 1,
gender: "f",
avatar: "/images/avatars/ttsreaderServer.gcp.en-GB-Standard-A.webp",
demo: "/audio/ttsreaderServer.gcp.en-GB-Standard-A.mp3",
},
{
voiceURI: "ttsreaderServer.gcp.en-GB-Standard-D",
name: "Noah Premium",
lang: "en-GB",
localService: false,
default: true,
premiumLevel: 1,
gender: "m",
avatar: "/images/avatars/ttsreaderServer.gcp.en-GB-Standard-D.webp",
demo: "/audio/ttsreaderServer.gcp.en-GB-Standard-D.mp3",
},
{
voiceURI: "ttsreaderServer.gcp.en-GB-Standard-N",
name: "Lilly Premium",
lang: "en-GB",
localService: false,
default: true,
premiumLevel: 1,
gender: "f",
avatar: "/images/avatars/ttsreaderServer.gcp.en-GB-Standard-N.webp",
demo: "/audio/ttsreaderServer.gcp.en-GB-Standard-N.mp3",
},
{
voiceURI: "ttsreaderServer.gcp.en-US-Chirp-HD-D",
name: "John Premium",
lang: "en-US",
localService: false,
default: true,
premiumLevel: 2,
gender: "m",
avatar: "/images/avatars/ttsreaderServer.gcp.en-US-Chirp-HD-D.webp",
demo: "/audio/ttsreaderServer.gcp.en-US-Chirp-HD-D.mp3",
},
/*{
voiceURI: "ttsreaderServer.gcp.en-US-Chirp-HD-F",
name: "Sarah Premium",
lang: "en-US",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
avatar: "/images/avatars/ttsreaderServer.gcp.en-US-Chirp-HD-F.webp",
demo: "/audio/ttsreaderServer.gcp.en-US-Chirp-HD-F.mp3",
},
{
voiceURI: "ttsreaderServer.gcp.en-US-Chirp-HD-O",
name: "Rachel Premium",
lang: "en-US",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
avatar: "/images/avatars/ttsreaderServer.gcp.en-US-Chirp-HD-O.webp",
demo: "/audio/ttsreaderServer.gcp.en-US-Chirp-HD-O.mp3",
},
{
voiceURI: "ttsreaderServer.gcp.en-GB-Wavenet-N",
name: "Rebecca Premium",
lang: "en-GB",
localService: false,
default: true,
premiumLevel: 2,
gender: "f",
avatar: "/images/avatars/ttsreaderServer.gcp.en-GB-Wavenet-N.webp",
demo: "/audio/ttsreaderServer.gcp.en-GB-Wavenet-N.mp3",
},*/
];
}
if (typeof module != 'undefined') {
module.exports = { ServerVoices };
}
},{}],4:[function(require,module,exports){
const SHA256 = require("crypto-js/sha256");
const { ServerTts } = require("./serverTts");
console.log(ServerTts);
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, onError}
utterance: {},
_googleBugTimeout: null,
_speakTimeout: null,
_canceledAtMs: 0,
_isServerTTS: false,
_defaultListener: {
onInit: (voices) => {
console.log('onInit ', voices);
},
onStart: () => {
console.log('onStart');
},
onDone: () => {
console.log('onDone');
},
onError: (error) => {
console.log('onError ', error);
},
onVoicesChanged: (updatedVoices) => {
console.log('onVoicesChanged ', updatedVoices);
}
},
init: function (listener, isToAddServerTTS) {
if (listener) {
this.setListener(listener, isToAddServerTTS);
}
this._isServerTTS = isToAddServerTTS || false;
this._populateVoices(isToAddServerTTS);
speechSynthesis.onvoiceschanged = () => { this._populateVoices(isToAddServerTTS); };
},
setListener: function (listener, isToAddServerTTS) {
this.listener = listener || this._defaultListener;
},
removeLocalGoogleVoices: function () {
this.voicesIncludingGoogle = [...this.voices];
this.voices=this.voices.filter(v=>!v.voiceURI.includes('Google '));
if (this.voice && !this.voices.includes(this.voice)) {
// Set the voice by language:
let lang = this.voice.lang;
this.voice = null;
this.setBestMatchingVoice(null,null,lang);
}
this.listener.onVoicesChanged(this.voices);
},
bringBackGoogleVoices: function () {
this.voices = [...this.voicesIncludingGoogle];
this.voicesIncludingGoogle = null;
this.listener.onVoicesChanged(this.voices);
},
runSilentTest: function () {
let startTime = Date.now();
let timer;
const utterance=new SpeechSynthesisUtterance('hi');
utterance.volume=0;
let voice=speechSynthesis.getVoices().find(v=>v.voiceURI==="Google UK English Male");
if (!voice) {
return;
}
utterance.voice = voice;
utterance.voiceURI = voice.voiceURI;
utterance.lang = voice.lang;
timer = setTimeout(()=>{
this.removeLocalGoogleVoices();
if (window.gtag) {
gtag('event','silent_test_failed',{value:'1'})
}
},3000);
utterance.onstart=()=>{
console.log('onstart in ' + (Date.now()-startTime));
clearTimeout(timer);
if (window.gtag) {
gtag('event','silent_test_success',{value:'1'})
}
if (this.voicesIncludingGoogle) {
this.bringBackGoogleVoices();
}
speechSynthesis.cancel();
};
utterance.onend=()=>{
console.log('onend in ' + (Date.now()-startTime));
if (window.gtag) {
gtag('event','silent_test_success',{value:'1'})
}
clearTimeout(timer);
};
console.log('calling speak: ' + (Date.now()-startTime));
speechSynthesis.speak(utterance);
},
/// 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 (isToAddServerTTS) {
// TODO: Add server tts voices if isToAddServerTTS is true.
let voices = window.speechSynthesis.getVoices();
if (!voices || voices.length<1) {
// Wait for webspeech api voices...
return;
}
console.log('populating voices ', isToAddServerTTS );
if (isToAddServerTTS) {
// Add server tts voices
let additionalVoices = ServerTts.getVoices();
console.log('additionalVoices: ', additionalVoices);
for (const additionalVoice of additionalVoices) {
voices.push(additionalVoice);
}
}
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;
},
_runOnWebspeechApiStart: function(ev) {
//console.log("_defaultOnStart utterance ", ev);
this.startedAndNotTerminatedCounter++;
this._solveChromeBug();
},
_runOnWebspeechApiEnd: function(ev) {
//console.log("_defaultOnEnd utterance ", ev);
if (this.startedAndNotTerminatedCounter>0) {
this.startedAndNotTerminatedCounter--;
}
this._clearUtteranceTimeouts();
},
_runOnWebspeechApiError: 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;
},
// When done - sends the URL of the audio blob of the generated audio.
// Where utt = {text, voiceURI, rate} No need for id as it will be generated by the engine.
generateAudioSync: function (utt, authToken, onDone, onError, optionalParamsAsJson) {
let id = "" + SHA256(utt.text + utt.langBCP47 + utt.voiceURI + utt.rate);
ServerTts.generateAudioSync(utt.text, utt.voiceURI, utt.langBCP47, utt.rate, id, authToken, onDone, onError, optionalParamsAsJson);
},
// where utt = {text, voiceURI, rate} No need for id as it will be generated by the engine.
speakAndBuffer: function(utt, bufferArray, authToken) {
if (utt.voiceURI.startsWith("ttsreaderServer")) {
// Server side tts
let text = this._prepareTextForSynthesis(utt.text);
if (!text) {
this.listener.onStart();
this.listener.onDone();
return;
}
// Generate id by hashing sha256 of: text + voiceURI + rate
let id = "" + SHA256(text + utt.langBCP47 + utt.voiceURI + utt.rate);
// Is utt in buffer & renderStatus === "done"? If yes - remove its on ready listener - and simply play it!
const existingUtt = ServerTts.buffer.find(u => u.id === id);
if (existingUtt && existingUtt.renderStatus === "done") {
console.log(`Utterance ${id} already in buffer and ready.`);
existingUtt.wasPlayed = false; // Reset the flag to allow re-use.
existingUtt.onSuccess = () => {} // Reset the listener to avoid double calls.
// Play now:
ServerTts.speak(id, {
onStart: this.listener.onStart,
onDone: this.listener.onDone,
onError: this.listener.onError
});
} else if (existingUtt && existingUtt.renderStatus === "waiting") {
console.log(`Utterance ${id} already in buffer and NOT ready.`);
existingUtt.wasPlayed = false; // Reset the flag to allow re-use.
// Make sure that it has the correct onAudioReady listener. It may override previous one:
// TODO: On audio received => speak it. Implement this in ServerTts.js
existingUtt.onSuccess = () => {
ServerTts.speak(id, {
onStart: this.listener.onStart,
onDone: this.listener.onDone,
onError: this.listener.onError
});
};
existingUtt.onError = () => {
console.error('Error buffering utterance: ', error);
this.listener.onError(error);
};
} else {
// Either not in buffer, or renderStatus is "error".
// We will buffer it now and then speak it - or fire error if buffering fails.
ServerTts.bufferNewUtterance(text, utt.voiceURI, utt.langBCP47, utt.rate, id, authToken,
()=> {
ServerTts.speak(id, {
onStart: this.listener.onStart,
onDone: this.listener.onDone,
onError: this.listener.onError
});
},
(error)=> {
console.error('Error buffering utterance: ', error);
this.listener.onError(error);
},
utt.isTest
);
}
// Now buffer the rest of the utts if needed:
for (const bufferUtt of bufferArray) {
let bufferText = this._prepareTextForSynthesis(bufferUtt.text);
let bufferId = "" + SHA256(bufferText + bufferUtt.langBCP47 + bufferUtt.voiceURI + bufferUtt.rate);
let existingBufferUtt = ServerTts.buffer.find(u => u.id === bufferId);
if (!existingBufferUtt || existingBufferUtt.renderStatus === "error") {
ServerTts.bufferNewUtterance(bufferText, bufferUtt.voiceURI, bufferUtt.langBCP47, bufferUtt.rate, bufferId, authToken,
() => {
// Do nothing, it's simply bg buffering.
}, (error) => {
// Do nothing, it's simply bg buffering.
},
bufferUtt.isTest
);
} // Otherwise - it's already in the buffer - do nothing.
}
} else {
// Local tts Web Speech API:
this.setVoiceByUri(utt.voiceURI);
// Make sure rate is within (0.5, 2):
utt.rate = Math.min(utt.rate, 2); // max rate allowed is 2
utt.rate = Math.max(utt.rate, 0.5); // min rate allowed is 0.5
this.setRate(utt.rate);
this.speakOut(utt.text);
}
},
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) {
if (utterance.voice.voiceURI.toLowerCase().includes("google") || utterance.voiceURI?.toLowerCase()?.includes("google")) {
console.log('voice URI includes google - do reset');
self.removeLocalGoogleVoices = function () {
console.log("removeLocalGoogleVoices reset");
};
}
console.log('onstart ', ev);
self._runOnWebspeechApiStart(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._runOnWebspeechApiEnd(ev);
if (self.listener && self.listener.onDone) {
self.listener.onDone();
}
utterance = null;
};
utterance.onerror = function (ev) {
//console.log('error ', ev);
self._runOnWebspeechApiError(ev);
utterance = null;
};
console.log('tts - ronen right away')
this._speakUtterance(utterance);
},
stop() {
ServerTts.stop(); // Safe to call
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);
}
}
};
},{"./serverTts":2,"crypto-js/sha256":7}],5:[function(require,module,exports){
exports.TtsEngine = require('./helpers/ttsEngine').TtsEngine;
},{"./helpers/ttsEngine":4}],6:[function(require,module,exports){
(function (global){(function (){
;(function (root, factory) {
if (typeof exports === "object") {
// CommonJS
module.exports = exports = factory();
}
else if (typeof define === "function" && define.amd) {
// AMD
define([], factory);
}
else {
// Global (browser)
root.CryptoJS = factory();
}
}(this, function () {
/*globals window, global, require*/
/**
* CryptoJS core components.
*/
var CryptoJS = CryptoJS || (function (Math, undefined) {
var crypto;
// Native crypto from window (Browser)
if (typeof window !== 'undefined' && window.crypto) {
crypto = window.crypto;
}
// Native crypto in web worker (Browser)
if (typeof self !== 'undefined' && self.crypto) {
crypto = self.crypto;
}
// Native crypto from worker
if (typeof globalThis !== 'undefined' && globalThis.crypto) {
crypto = globalThis.crypto;
}
// Native (experimental IE 11) crypto from window (Browser)
if (!crypto && typeof window !== 'undefined' && window.msCrypto) {
crypto = window.msCrypto;
}
// Native crypto from global (NodeJS)
if (!crypto && typeof global !== 'undefined' && global.crypto) {
crypto = global.crypto;
}
// Native crypto import via require (NodeJS)
if (!crypto && typeof require === 'function') {
try {
crypto = require('crypto');
} catch (err) {}
}
/*
* Cryptographically secure pseudorandom number generator
*
* As Math.random() is cryptographically not safe to use
*/
var cryptoSecureRandomInt = function () {
if (crypto) {
// Use getRandomValues method (Browser)
if (typeof crypto.getRandomValues === 'function') {
try {
return crypto.getRandomValues(new Uint32Array(1))[0];
} catch (err) {}
}
// Use randomBytes method (NodeJS)
if (typeof crypto.randomBytes === 'function') {
try {
return crypto.randomBytes(4).readInt32LE();
} catch (err) {}
}
}
throw new Error('Native crypto module could not be used to get secure random number.');
};
/*
* Local polyfill of Object.create
*/
var create = Object.create || (function () {
function F() {}
return function (obj) {
var subtype;
F.prototype = obj;
subtype = new F();
F.prototype = null;
return subtype;
};
}());
/**
* CryptoJS namespace.
*/
var C = {};
/**
* Library namespace.
*/
var C_lib = C.lib = {};
/**
* Base object for prototypal inheritance.
*/
var Base = C_lib.Base = (function () {
return {
/**
* Creates a new object that inherits from this object.
*
* @param {Object} overrides Properties to copy into the new object.
*
* @return {Object} The new object.
*
* @static
*
* @example
*
* var MyType = CryptoJS.lib.Base.extend({
* field: 'value',
*
* method: function () {
* }
* });
*/
extend: function (overrides) {
// Spawn
var subtype = create(this);
// Augment
if (overrides) {
subtype.mixIn(overrides);
}
// Create default initializer
if (!subtype.hasOwnProperty('init') || this.init === subtype.init) {
subtype.init = function () {
subtype.$super.init.apply(this, arguments);
};
}
// Initializer's prototype is the subtype object
subtype.init.prototype = subtype;
// Reference supertype
subtype.$super = this;
return subtype;
},
/**
* Extends this object and runs the init method.
* Arguments to create() will be passed to init().
*
* @return {Object} The new object.
*
* @static
*
* @example
*
* var instance = MyType.create();
*/
create: function () {
var instance = this.extend();
instance.init.apply(instance, arguments);
return instance;
},
/**
* Initializes a newly created object.
* Override this method to add some logic when your objects are created.
*
* @example
*
* var MyType = CryptoJS.lib.Base.extend({
* init: function () {
* // ...
* }
* });
*/
init: function () {
},
/**
* Copies properties into this object.
*
* @param {Object} properties The properties to mix in.
*
* @example
*
* MyType.mixIn({
* field: 'value'
* });
*/
mixIn: function (properties) {
for (var propertyName in properties) {
if (properties.hasOwnProperty(propertyName)) {
this[propertyName] = properties[propertyName];
}
}
// IE won't copy toString using the loop above
if (properties.hasOwnProperty('toString')) {
this.toString = properties.toString;
}
},
/**
* Creates a copy of this object.
*
* @return {Object} The clone.
*
* @example
*
* var clone = instance.clone();
*/
clone: function () {
return this.init.prototype.extend(this);
}
};
}());
/**
* An array of 32-bit words.
*
* @property {Array} words The array of 32-bit words.
* @property {number} sigBytes The number of significant bytes in this word array.
*/
var WordArray = C_lib.WordArray = Base.extend({
/**
* Initi