| // Copyright 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview Sends Text-To-Speech commands to Chrome's native TTS |
| * extension API. |
| * |
| */ |
| |
| goog.provide('cvox.TtsBackground'); |
| |
| goog.require('cvox.AbstractTts'); |
| goog.require('cvox.ChromeTtsBase'); |
| goog.require('cvox.ChromeVox'); |
| goog.require('cvox.MathMap'); |
| goog.require('goog.i18n.MessageFormat'); |
| |
| |
| /** |
| * @constructor |
| * @param {string} textString The string of text to be spoken. |
| * @param {Object} properties Speech properties to use for this utterance. |
| */ |
| cvox.Utterance = function(textString, properties) { |
| this.textString = textString; |
| this.properties = properties; |
| this.id = cvox.Utterance.nextUtteranceId_++; |
| }; |
| |
| /** |
| * The next utterance id to use. |
| * @type {number} |
| * @private |
| */ |
| cvox.Utterance.nextUtteranceId_ = 1; |
| |
| /** |
| * @constructor |
| * @param {boolean=} opt_enableMath Whether to process math. Used when running |
| * on forge. Defaults to true. |
| * @extends {cvox.ChromeTtsBase} |
| */ |
| cvox.TtsBackground = function(opt_enableMath) { |
| opt_enableMath = opt_enableMath == undefined ? true : opt_enableMath; |
| goog.base(this); |
| this.currentVoice = localStorage['voiceName'] || ''; |
| |
| this.ttsProperties['rate'] = (parseFloat(localStorage['rate']) || |
| this.propertyDefault['rate']); |
| this.ttsProperties['pitch'] = (parseFloat(localStorage['pitch']) || |
| this.propertyDefault['pitch']); |
| this.ttsProperties['volume'] = (parseFloat(localStorage['volume']) || |
| this.propertyDefault['volume']); |
| |
| // Use the current locale as the speech language if not otherwise |
| // specified. |
| if (this.ttsProperties['lang'] == undefined) { |
| this.ttsProperties['lang'] = |
| chrome.i18n.getMessage('@@ui_locale').replace('_', '-'); |
| } |
| |
| this.lastEventType = 'end'; |
| |
| this.setPreferredVoice_(); |
| if (!this.currentVoice) { |
| this.setDefaultVoiceName_(); |
| } |
| |
| /** @private {number} */ |
| this.currentPunctuationEcho_ = |
| parseInt(localStorage[cvox.AbstractTts.PUNCTUATION_ECHO] || 1, 10); |
| |
| /** |
| * @type {!Array.<{name:(string), |
| * msg:(string), |
| * regexp:(RegExp), |
| * clear:(boolean)}>} |
| * @private |
| */ |
| this.punctuationEchoes_ = [ |
| /** |
| * Punctuation echoed for the 'none' option. |
| */ |
| { |
| name: 'none', |
| msg: 'no_punctuation', |
| regexp: /[-$#"()*;:<>\n\\\/+='~`@_]/g, |
| clear: true |
| }, |
| |
| /** |
| * Punctuation echoed for the 'some' option. |
| */ |
| { |
| name: 'some', |
| msg: 'some_punctuation', |
| regexp: /[$#"*<>\\\/\{\}+=~`%]/g, |
| clear: false |
| }, |
| |
| /** |
| * Punctuation echoed for the 'all' option. |
| */ |
| { |
| name: 'all', |
| msg: 'all_punctuation', |
| regexp: /[-$#"()*;:<>\n\\\/\{\}\[\]+='~`!@_.,?%]/g, |
| clear: false |
| } |
| ]; |
| |
| /** |
| * A list of punctuation characters that should always be spliced into output |
| * even with literal word substitutions. |
| * This is important for tts prosity. |
| * @type {!Array.<string>} |
| * @private |
| */ |
| this.retainPunctuation_ = |
| [';', '?', '!', '\'']; |
| |
| /** |
| * Mapping for math elements. |
| * @type {cvox.MathMap} |
| */ |
| this.mathmap = opt_enableMath ? new cvox.MathMap() : null; |
| |
| /** |
| * The id of a callback returned from setTimeout. |
| * @type {number|undefined} |
| */ |
| this.timeoutId_; |
| |
| try { |
| /** |
| * @type {Object.<string, string>} |
| * @private |
| * @const |
| */ |
| this.PHONETIC_MAP_ = /** @type {Object.<string, string>} */( |
| JSON.parse(cvox.ChromeVox.msgs.getMsg('phonetic_map'))); |
| } catch (e) { |
| console.log('Error; unable to parse phonetic map msg.'); |
| } |
| |
| /** |
| * Capturing tts event listeners. |
| * @type {Array.<cvox.TtsCapturingEventListener>} |
| * @private |
| */ |
| this.capturingTtsEventListeners_ = []; |
| |
| /** |
| * The current utterance. |
| * @type {cvox.Utterance} |
| * @private |
| */ |
| this.currentUtterance_ = null; |
| |
| /** |
| * The utterance queue. |
| * @type {Array.<cvox.Utterance>} |
| * @private |
| */ |
| this.utteranceQueue_ = []; |
| }; |
| goog.inherits(cvox.TtsBackground, cvox.ChromeTtsBase); |
| |
| |
| /** |
| * The amount of time to wait before speaking a phonetic word for a |
| * letter. |
| * @type {number} |
| * @private |
| * @const |
| */ |
| cvox.TtsBackground.PHONETIC_DELAY_MS_ = 1000; |
| |
| /** |
| * The list of properties allowed to be passed to the chrome.tts.speak API. |
| * Anything outside this list will be stripped. |
| * @type {Array.<string>} |
| * @private |
| * @const |
| */ |
| cvox.TtsBackground.ALLOWED_PROPERTIES_ = [ |
| 'desiredEventTypes', |
| 'enqueue', |
| 'extensionId', |
| 'gender', |
| 'lang', |
| 'onEvent', |
| 'pitch', |
| 'rate', |
| 'requiredEventTypes', |
| 'voiceName', |
| 'volume']; |
| |
| /** |
| * Sets the current voice to the one that the user selected on the options page |
| * if that voice exists. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.setPreferredVoice_ = function() { |
| var self = this; |
| chrome.tts.getVoices( |
| function(voices) { |
| for (var i = 0, v; v = voices[i]; i++) { |
| if (v['voiceName'] == localStorage['voiceName']) { |
| self.currentVoice = v['voiceName']; |
| return; |
| } |
| } |
| }); |
| }; |
| |
| |
| /** @override */ |
| cvox.TtsBackground.prototype.speak = function( |
| textString, queueMode, properties) { |
| goog.base(this, 'speak', textString, queueMode, properties); |
| |
| if (!properties) { |
| properties = {}; |
| } |
| if (queueMode === undefined) { |
| queueMode = cvox.AbstractTts.QUEUE_MODE_QUEUE; |
| } |
| |
| // Chunk to improve responsiveness. Use a replace/split pattern in order to |
| // retain the original punctuation. |
| var splitTextString = textString.replace(/([-\n\r.,!?;])(\s)/g, '$1$2|'); |
| splitTextString = splitTextString.split('|'); |
| // Since we are substituting the chunk delimiters back into the string, only |
| // recurse when there are more than 2 split items. This should result in only |
| // one recursive call. |
| if (splitTextString.length > 2) { |
| var startCallback = properties['startCallback']; |
| var endCallback = properties['endCallback']; |
| for (var i = 0; i < splitTextString.length; i++) { |
| var propertiesCopy = {}; |
| for (var p in properties) { |
| propertiesCopy[p] = properties[p]; |
| } |
| propertiesCopy['startCallback'] = i == 0 ? startCallback : null; |
| propertiesCopy['endCallback'] = |
| i == (splitTextString.length - 1) ? endCallback : null; |
| this.speak(splitTextString[i], queueMode, propertiesCopy); |
| queueMode = cvox.AbstractTts.QUEUE_MODE_QUEUE; |
| } |
| return this; |
| } |
| |
| textString = this.preprocess(textString, properties); |
| |
| // TODO(dtseng): Google TTS has bad performance when speaking numbers. This |
| // pattern causes ChromeVox to read numbers as digits rather than words. |
| textString = this.getNumberAsDigits_(textString); |
| |
| // TODO(dtseng): Google TTS flushes the queue when encountering strings of |
| // this pattern which stops ChromeVox speech. |
| if (!textString || !textString.match(/\w+/g)) { |
| // We still want to callback for listeners in our content script. |
| if (properties['startCallback']) { |
| try { |
| properties['startCallback'](); |
| } catch (e) { |
| } |
| } |
| if (properties['endCallback']) { |
| try { |
| properties['endCallback'](); |
| } catch (e) { |
| } |
| } |
| if (queueMode === cvox.AbstractTts.QUEUE_MODE_FLUSH) { |
| this.stop(); |
| } |
| return this; |
| } |
| |
| var mergedProperties = this.mergeProperties(properties); |
| |
| if (this.currentVoice && (this.currentVoice == localStorage['voiceName'])) { |
| mergedProperties['voiceName'] = this.currentVoice; |
| } |
| if (localStorage['voiceName'] && |
| this.currentVoice != localStorage['voiceName']) { |
| this.setPreferredVoice_(); |
| } |
| |
| if (queueMode == cvox.AbstractTts.QUEUE_MODE_CATEGORY_FLUSH && |
| !mergedProperties['category']) { |
| queueMode = cvox.AbstractTts.QUEUE_MODE_FLUSH; |
| } |
| |
| var utterance = new cvox.Utterance(textString, mergedProperties); |
| this.speakUsingQueue_(utterance, queueMode); |
| }; |
| |
| /** |
| * Use the speech queue to handle the given speech request. |
| * @param {cvox.Utterance} utterance The utterance to speak. |
| * @param {number} queueMode The queue mode. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.speakUsingQueue_ = function(utterance, queueMode) { |
| // First, take care of removing the current utterance and flushing |
| // anything from the queue we need to. If we remove the current utterance, |
| // make a note that we're going to stop speech. |
| if (queueMode == cvox.AbstractTts.QUEUE_MODE_FLUSH || |
| queueMode == cvox.AbstractTts.QUEUE_MODE_CATEGORY_FLUSH) { |
| if (this.shouldCancel_(this.currentUtterance_, utterance, queueMode)) { |
| this.cancelUtterance_(this.currentUtterance_); |
| this.currentUtterance_ = null; |
| } |
| var i = 0; |
| while (i < this.utteranceQueue_.length) { |
| if (this.shouldCancel_( |
| this.utteranceQueue_[i], utterance, queueMode)) { |
| this.cancelUtterance_(this.utteranceQueue_[i]); |
| this.utteranceQueue_.splice(i, 1); |
| } else { |
| i++; |
| } |
| } |
| } |
| |
| // Next, add the new utterance to the queue. |
| this.utteranceQueue_.push(utterance); |
| |
| // Now start speaking the next item in the queue. |
| this.startSpeakingNextItemInQueue_(); |
| }; |
| |
| /** |
| * If nothing is speaking, pop the first item off the speech queue and |
| * start speaking it. This is called when a speech request is made and |
| * when the current utterance finishes speaking. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.startSpeakingNextItemInQueue_ = function() { |
| if (this.currentUtterance_) { |
| return; |
| } |
| |
| if (this.utteranceQueue_.length == 0) { |
| return; |
| } |
| |
| this.currentUtterance_ = this.utteranceQueue_.shift(); |
| var utteranceId = this.currentUtterance_.id; |
| |
| this.currentUtterance_.properties['onEvent'] = goog.bind(function(event) { |
| this.onTtsEvent_(event, utteranceId); |
| }, this); |
| |
| var validatedProperties = {}; |
| for (var i = 0; i < cvox.TtsBackground.ALLOWED_PROPERTIES_.length; i++) { |
| var p = cvox.TtsBackground.ALLOWED_PROPERTIES_[i]; |
| if (this.currentUtterance_.properties[p]) { |
| validatedProperties[p] = this.currentUtterance_.properties[p]; |
| } |
| } |
| |
| chrome.tts.speak(this.currentUtterance_.textString, |
| validatedProperties); |
| }; |
| |
| /** |
| * Called when we get a speech event from Chrome. We ignore any event |
| * that doesn't pertain to the current utterance, but when speech starts |
| * or ends we optionally call callback functions, and start speaking the |
| * next utterance if there's another one enqueued. |
| * @param {Object} event The TTS event from chrome. |
| * @param {number} utteranceId The id of the associated utterance. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.onTtsEvent_ = function(event, utteranceId) { |
| this.lastEventType = event['type']; |
| |
| // Ignore events sent on utterances other than the current one. |
| if (!this.currentUtterance_ || |
| utteranceId != this.currentUtterance_.id) { |
| return; |
| } |
| |
| var utterance = this.currentUtterance_; |
| |
| switch (event.type) { |
| case 'start': |
| this.capturingTtsEventListeners_.forEach(function(listener) { |
| listener.onTtsStart(); |
| }); |
| if (utterance.properties['startCallback']) { |
| try { |
| utterance.properties['startCallback'](); |
| } catch (e) { |
| } |
| } |
| break; |
| case 'end': |
| this.capturingTtsEventListeners_.forEach(function(listener) { |
| listener.onTtsEnd(); |
| }); |
| // Intentionally falls through. |
| case 'interrupted': |
| this.cancelUtterance_(utterance); |
| this.currentUtterance_ = null; |
| this.startSpeakingNextItemInQueue_(); |
| break; |
| case 'error': |
| this.onError_(event['errorMessage']); |
| this.startSpeakingNextItemInQueue_(); |
| break; |
| } |
| }; |
| |
| /** |
| * Determines if |utteranceToCancel| should be canceled (interrupted if |
| * currently speaking, or removed from the queue if not), given the new |
| * utterance we want to speak and the queue mode. If the queue mode is |
| * QUEUE or FLUSH, the logic is straightforward. If the queue mode is |
| * CATEGORY_FLUSH, we only flush utterances with the same category. |
| * |
| * @param {cvox.Utterance} utteranceToCancel The utterance in question. |
| * @param {cvox.Utterance} newUtterance The new utterance we're enqueueing. |
| * @param {number} queueMode The queue mode. |
| * @return {boolean} True if this utterance should be canceled. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.shouldCancel_ = |
| function(utteranceToCancel, newUtterance, queueMode) { |
| if (!utteranceToCancel) { |
| return false; |
| } |
| if (utteranceToCancel.properties['doNotInterrupt']) { |
| return false; |
| } |
| switch (queueMode) { |
| case cvox.AbstractTts.QUEUE_MODE_QUEUE: |
| return false; |
| case cvox.AbstractTts.QUEUE_MODE_FLUSH: |
| return true; |
| case cvox.AbstractTts.QUEUE_MODE_CATEGORY_FLUSH: |
| return (utteranceToCancel.properties['category'] == |
| newUtterance.properties['category']); |
| } |
| }; |
| |
| /** |
| * Do any cleanup necessary to cancel an utterance, like callings its |
| * callback function if any. |
| * @param {cvox.Utterance} utterance The utterance to cancel. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.cancelUtterance_ = function(utterance) { |
| if (utterance && utterance.properties['endCallback']) { |
| try { |
| utterance.properties['endCallback'](); |
| } catch (e) { |
| } |
| } |
| }; |
| |
| /** @override */ |
| cvox.TtsBackground.prototype.increaseOrDecreaseProperty = |
| function(propertyName, increase) { |
| goog.base(this, 'increaseOrDecreaseProperty', propertyName, increase); |
| localStorage[propertyName] = this.ttsProperties[propertyName]; |
| }; |
| |
| /** @override */ |
| cvox.TtsBackground.prototype.isSpeaking = function() { |
| goog.base(this, 'isSpeaking'); |
| return this.lastEventType != 'end'; |
| }; |
| |
| /** @override */ |
| cvox.TtsBackground.prototype.stop = function() { |
| goog.base(this, 'stop'); |
| |
| this.cancelUtterance_(this.currentUtterance_); |
| this.currentUtterance_ = null; |
| |
| for (var i = 0; i < this.utteranceQueue_.length; i++) { |
| this.cancelUtterance_(this.utteranceQueue_[i]); |
| } |
| this.utteranceQueue_.length = 0; |
| |
| chrome.tts.stop(); |
| }; |
| |
| /** @override */ |
| cvox.TtsBackground.prototype.addCapturingEventListener = function(listener) { |
| this.capturingTtsEventListeners_.push(listener); |
| }; |
| |
| /** |
| * An error handler passed as a callback to chrome.tts.speak. |
| * @param {string} errorMessage Describes the error (set by onEvent). |
| * @private |
| */ |
| cvox.TtsBackground.prototype.onError_ = function(errorMessage) { |
| // Reset voice related parameters. |
| delete localStorage['voiceName']; |
| }; |
| |
| /** |
| * Converts an engine property value to a percentage from 0.00 to 1.00. |
| * @param {string} property The property to convert. |
| * @return {?number} The percentage of the property. |
| */ |
| cvox.TtsBackground.prototype.propertyToPercentage = function(property) { |
| return (this.ttsProperties[property] - this.propertyMin[property]) / |
| Math.abs(this.propertyMax[property] - this.propertyMin[property]); |
| }; |
| |
| |
| /** |
| * @override |
| */ |
| cvox.TtsBackground.prototype.preprocess = function(text, properties) { |
| properties = properties ? properties : {}; |
| |
| // Perform specialized processing, such as mathematics. |
| if (properties.math) { |
| text = this.preprocessMath_(text, properties.math); |
| } |
| |
| // Perform generic processing. |
| text = goog.base(this, 'preprocess', text, properties); |
| |
| // Perform any remaining processing such as punctuation expansion. |
| var pE = null; |
| if (properties[cvox.AbstractTts.PUNCTUATION_ECHO]) { |
| for (var i = 0; pE = this.punctuationEchoes_[i]; i++) { |
| if (properties[cvox.AbstractTts.PUNCTUATION_ECHO] == pE.name) { |
| break; |
| } |
| } |
| } else { |
| pE = this.punctuationEchoes_[this.currentPunctuationEcho_]; |
| } |
| text = |
| text.replace(pE.regexp, this.createPunctuationReplace_(pE.clear)); |
| |
| // Try pronouncing phonetically for single characters. Cancel previous calls |
| // to pronouncePhonetically_ if we fail to pronounce on this invokation or if |
| // this text is math which should never be pronounced phonetically. |
| if (properties.math || |
| !properties['phoneticCharacters'] || |
| !this.pronouncePhonetically_(text)) { |
| this.clearTimeout_(); |
| } |
| |
| // Try looking up in our unicode tables for a short description. |
| if (!properties.math && text.length == 1 && this.mathmap) { |
| text = this.mathmap.store.lookupString( |
| text.toLowerCase(), |
| cvox.MathStore.createDynamicConstraint('default', 'short')) || text; |
| } |
| |
| // Remove all whitespace from the beginning and end, and collapse all |
| // inner strings of whitespace to a single space. |
| text = text.replace(/\s+/g, ' ').replace(/^\s+|\s+$/g, ''); |
| |
| return text; |
| }; |
| |
| |
| /** |
| * Method that cycles among the available punctuation echo levels. |
| * @return {string} The resulting punctuation level message id. |
| */ |
| cvox.TtsBackground.prototype.cyclePunctuationEcho = function() { |
| this.currentPunctuationEcho_ = |
| (this.currentPunctuationEcho_ + 1) % this.punctuationEchoes_.length; |
| localStorage[cvox.AbstractTts.PUNCTUATION_ECHO] = |
| this.currentPunctuationEcho_; |
| return this.punctuationEchoes_[this.currentPunctuationEcho_].msg; |
| }; |
| |
| |
| /** |
| * Process a math expression into a string suitable for a speech engine. |
| * @param {string} text Text representing a math expression. |
| * @param {Object= } math Parameter containing information how to |
| * process the math expression. |
| * @return {string} The string with a spoken version of the math expression. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.preprocessMath_ = function(text, math) { |
| if (!this.mathmap) { |
| return text; |
| } |
| var result = ''; |
| var dynamicCstr = cvox.MathStore.createDynamicConstraint( |
| math['domain'], math['style']); |
| result = this.mathmap.store.lookupString(text, dynamicCstr); |
| if (result) { |
| return result; |
| } |
| return text; |
| }; |
| |
| |
| /** |
| * Converts a number into space-separated digits. |
| * For numbers containing 4 or fewer digits, we return the original number. |
| * This ensures that numbers like 123,456 or 2011 are not "digitized" while |
| * 123456 is. |
| * @param {string} text The text to process. |
| * @return {string} A string with all numbers converted. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.getNumberAsDigits_ = function(text) { |
| return text.replace(/\d+/g, function(num) { |
| if (num.length <= 4) { |
| return num; |
| } |
| return num.split('').join(' '); |
| }); |
| }; |
| |
| |
| /** |
| * Constructs a function for string.replace that handles description of a |
| * punctuation character. |
| * @param {boolean} clear Whether we want to use whitespace in place of match. |
| * @return {function(string): string} The replacement function. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.createPunctuationReplace_ = function(clear) { |
| return goog.bind(function(match) { |
| var retain = this.retainPunctuation_.indexOf(match) != -1 ? |
| match : ' '; |
| return clear ? retain : |
| ' ' + (new goog.i18n.MessageFormat(cvox.ChromeVox.msgs.getMsg( |
| cvox.AbstractTts.CHARACTER_DICTIONARY[match]))) |
| .format({'COUNT': 1}) + retain + ' '; |
| }, this); |
| }; |
| |
| |
| /** |
| * Pronounces single letters phonetically after some timeout. |
| * @param {string} text The text. |
| * @return {boolean} True if the text resulted in speech. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.pronouncePhonetically_ = function(text) { |
| if (!this.PHONETIC_MAP_) { |
| return false; |
| } |
| text = text.toLowerCase(); |
| text = this.PHONETIC_MAP_[text]; |
| if (text) { |
| this.clearTimeout_(); |
| var self = this; |
| this.timeoutId_ = setTimeout(function() { |
| self.speak(text, 1); |
| }, cvox.TtsBackground.PHONETIC_DELAY_MS_); |
| return true; |
| } |
| return false; |
| }; |
| |
| |
| /** |
| * Clears the last timeout set via setTimeout. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.clearTimeout_ = function() { |
| if (goog.isDef(this.timeoutId_)) { |
| clearTimeout(this.timeoutId_); |
| this.timeoutId_ = undefined; |
| } |
| }; |
| |
| /** |
| * Sets the name of a voice appropriate for the current locale preferring |
| * non-remote voices. |
| * @private |
| */ |
| cvox.TtsBackground.prototype.setDefaultVoiceName_ = function() { |
| chrome.tts.getVoices( |
| goog.bind(function(voices) { |
| var currentLocale = |
| chrome.i18n.getMessage('@@ui_locale').replace('_', '-'); |
| voices.sort(function(v1, v2) { |
| if (v1['remote'] && !v2['remote']) { |
| return 1; |
| } |
| if (!v1['remote'] && v2['remote']) { |
| return -1; |
| } |
| if (v1['lang'] == currentLocale && v2['lang'] != currentLocale) { |
| return -1; |
| } |
| if (v1['lang'] != currentLocale && v2['lang'] == currentLocale) { |
| return 1; |
| } |
| return 0; |
| }); |
| if (voices[0]) { |
| var voiceName = voices[0].voiceName; |
| this.currentVoice = voiceName; |
| } |
| }, this)); |
| }; |