UNPKG

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
(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