| // Copyright 2017 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. |
| |
| |
| 'use strict'; |
| |
| |
| /** |
| * Alias for document.getElementById. |
| * @param {string} id The ID of the element to find. |
| * @return {HTMLElement} The found element or null if not found. |
| */ |
| function $(id) { |
| // eslint-disable-next-line no-restricted-properties |
| return document.getElementById(id); |
| } |
| |
| |
| /** |
| * Get the preferred language for UI localization. Represents Chrome's UI |
| * language, which might not coincide with the user's "preferred" language |
| * in the Settings. For more details, see: |
| * - https://developer.mozilla.org/en/docs/Web/API/NavigatorLanguage/language |
| * - https://developer.mozilla.org/en/docs/Web/API/NavigatorLanguage/languages |
| * |
| * The returned value is a language version string as defined in |
| * <a href="http://www.ietf.org/rfc/bcp/bcp47.txt">BCP 47</a>. |
| * Examples: "en", "en-US", "cs-CZ", etc. |
| */ |
| function getChromeUILanguage() { |
| // In Chrome, |window.navigator.language| is not guaranteed to be equal to |
| // |window.navigator.languages[0]|. |
| return window.navigator.language; |
| } |
| |
| |
| /** |
| * Enum for keyboard event codes. |
| * @enum {!string} |
| * @const |
| */ |
| const KEYCODE = { |
| ENTER: 'Enter', |
| ESC: 'Escape', |
| NUMPAD_ENTER: 'NumpadEnter', |
| PERIOD: 'Period' |
| }; |
| |
| |
| /** |
| * The set of possible recognition errors. |
| * @enum {!number} |
| * @const |
| */ |
| const RecognitionError = { |
| NO_SPEECH: 0, |
| ABORTED: 1, |
| AUDIO_CAPTURE: 2, |
| NETWORK: 3, |
| NOT_ALLOWED: 4, |
| SERVICE_NOT_ALLOWED: 5, |
| BAD_GRAMMAR: 6, |
| LANGUAGE_NOT_SUPPORTED: 7, |
| NO_MATCH: 8, |
| OTHER: 9 |
| }; |
| |
| |
| /** |
| * Provides methods for communicating with the <a |
| * href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API"> |
| * Web Speech API</a>, error handling and executing search queries. |
| */ |
| let speech = {}; |
| |
| |
| /** |
| * Localized translations for messages used in the Speech UI. |
| * @type {{ |
| * audioError: string, |
| * details: string, |
| * languageError: string, |
| * learnMore: string, |
| * listening: string, |
| * networkError: string, |
| * noTranslation: string, |
| * noVoice: string, |
| * otherError: string, |
| * permissionError: string, |
| * ready: string, |
| * tryAgain: string, |
| * waiting: string |
| * }} |
| */ |
| speech.messages = { |
| audioError: '', |
| details: '', |
| languageError: '', |
| learnMore: '', |
| listening: '', |
| networkError: '', |
| noTranslation: '', |
| noVoice: '', |
| otherError: '', |
| permissionError: '', |
| ready: '', |
| tryAgain: '', |
| waiting: '' |
| }; |
| |
| |
| /** |
| * The set of controller states. |
| * @enum {number} |
| * @private |
| */ |
| speech.State_ = { |
| // Initial state of the controller. It is never re-entered. |
| // The only state from which the |speech.init()| method can be called. |
| // The UI overlay is hidden, recognition is inactive. |
| UNINITIALIZED: -1, |
| // Represents a ready to be activated state. If voice search is unsuccessful |
| // for any reason, the controller will return to this state |
| // using |speech.reset_()|. The UI overlay is hidden, recognition is inactive. |
| READY: 0, |
| // Indicates that speech recognition has started, but no audio has yet |
| // been captured. The UI overlay is visible, recognition is active. |
| STARTED: 1, |
| // Indicates that audio is being captured by the Web Speech API, but no |
| // speech has yet been recognized. The UI overlay is visible and indicating |
| // that audio is being captured, recognition is active. |
| AUDIO_RECEIVED: 2, |
| // Represents a state where speech has been recognized by the Web Speech API, |
| // but no resulting transcripts have yet been received back. The UI overlay is |
| // visible and indicating that audio is being captured, recognition is active. |
| SPEECH_RECEIVED: 3, |
| // Controller state where speech has been successfully recognized and text |
| // transcripts have been reported back. The UI overlay is visible |
| // and displaying intermediate results, recognition is active. |
| // This state remains until recognition ends successfully or due to an error. |
| RESULT_RECEIVED: 4, |
| // Indicates that speech recognition has failed due to an error |
| // (or a no match error) being received from the Web Speech API. |
| // A timeout may have occurred as well. The UI overlay is visible |
| // and displaying an error message, recognition is inactive. |
| ERROR_RECEIVED: 5, |
| // Represents a state where speech recognition has been stopped |
| // (either on success or failure) and the UI has not yet reset/redirected. |
| // The UI overlay is displaying results or an error message with a timeout, |
| // after which the site will either get redirected to search results |
| // (successful) or back to the NTP by hiding the overlay (unsuccessful). |
| STOPPED: 6 |
| }; |
| |
| |
| /** |
| * Threshold for considering an interim speech transcript result as "confident |
| * enough". The more confident the API is about a transcript, the higher the |
| * confidence (number between 0 and 1). |
| * @private {number} |
| * @const |
| */ |
| speech.RECOGNITION_CONFIDENCE_THRESHOLD_ = 0.5; |
| |
| /** |
| * Time in milliseconds to wait before closing the UI after an error has |
| * occured. This is a short timeout used when no click-target is present. |
| * @private {number} |
| * @const |
| */ |
| speech.ERROR_TIMEOUT_SHORT_MS_ = 3000; |
| |
| |
| /** |
| * Time in milliseconds to wait before closing the UI after an error has |
| * occured. This is a longer timeout used when there is a click-target is |
| * present. |
| * @private {number} |
| * @const |
| */ |
| speech.ERROR_TIMEOUT_LONG_MS_ = 8000; |
| |
| |
| /** |
| * Time in milliseconds to wait before closing the UI if no interaction has |
| * occured. |
| * @private {number} |
| * @const |
| */ |
| speech.IDLE_TIMEOUT_MS_ = 8000; |
| |
| |
| /** |
| * Maximum number of characters recognized before force-submitting a query. |
| * Includes characters of non-confident recognition transcripts. |
| * @private {number} |
| * @const |
| */ |
| speech.QUERY_LENGTH_LIMIT_ = 120; |
| |
| |
| /** |
| * Specifies the current state of the controller. |
| * Note: Different than the UI state. |
| * @private {speech.State_} |
| */ |
| speech.currentState_ = speech.State_.UNINITIALIZED; |
| |
| |
| /** |
| * The ID for the error timer. |
| * @private {number} |
| */ |
| speech.errorTimer_; |
| |
| |
| /** |
| * The duration of the timeout for the UI elements during an error state. |
| * Depending on the error state, we have different durations for the timeout. |
| * @private {number} |
| */ |
| speech.errorTimeoutMs_ = 0; |
| |
| |
| /** |
| * The last high confidence voice transcript received from the Web Speech API. |
| * This is the actual query that could potentially be submitted to Search. |
| * @private {string} |
| */ |
| speech.finalResult_; |
| |
| |
| /** |
| * Base URL for sending queries to Search. Includes trailing forward slash. |
| * @private {string} |
| */ |
| speech.googleBaseUrl_; |
| |
| |
| /** |
| * The ID for the idle timer. |
| * @private {number} |
| */ |
| speech.idleTimer_; |
| |
| |
| /** |
| * The last low confidence voice transcript received from the Web Speech API. |
| * @private {string} |
| */ |
| speech.interimResult_; |
| |
| |
| /** |
| * The Web Speech API object driving the speech recognition transaction. |
| * @private {!webkitSpeechRecognition} |
| */ |
| speech.recognition_; |
| |
| |
| /** |
| * Initialize the speech module as part of the local NTP. Adds event handlers |
| * and shows the fakebox microphone icon. |
| * @param {!string} googleBaseUrl Base URL for sending queries to Search. |
| * @param {!Object} translatedStrings Dictionary of localized string messages. |
| * @param {!HTMLElement} fakeboxMicrophoneElem Fakebox microphone icon element. |
| * @param {!Object} searchboxApiHandle SearchBox API handle. |
| */ |
| speech.init = function( |
| googleBaseUrl, translatedStrings, fakeboxMicrophoneElem, |
| searchboxApiHandle) { |
| if (speech.currentState_ != speech.State_.UNINITIALIZED) { |
| throw new Error( |
| 'Trying to re-initialize speech when not in UNINITIALIZED state.'); |
| } |
| |
| // Initialize event handlers. |
| fakeboxMicrophoneElem.hidden = false; |
| fakeboxMicrophoneElem.title = translatedStrings.fakeboxMicrophoneTooltip; |
| fakeboxMicrophoneElem.onmouseup = function(event) { |
| // If propagated, closes the overlay (click on the background). |
| event.stopPropagation(); |
| speech.start(); |
| }; |
| fakeboxMicrophoneElem.onkeydown = function(event) { |
| if (!event.repeat && |
| (event.code == KEYCODE.ENTER || event.code == KEYCODE.NUMPAD_ENTER)) { |
| speech.start(); |
| } |
| }; |
| window.addEventListener('keydown', speech.onKeyDown); |
| if (searchboxApiHandle.onfocuschange) { |
| throw new Error('OnFocusChange handler already set on searchbox.'); |
| } |
| searchboxApiHandle.onfocuschange = speech.onOmniboxFocused; |
| |
| // Initialize speech internal state. |
| speech.googleBaseUrl_ = googleBaseUrl; |
| speech.messages = { |
| audioError: translatedStrings.audioError, |
| details: translatedStrings.details, |
| languageError: translatedStrings.languageError, |
| learnMore: translatedStrings.learnMore, |
| listening: translatedStrings.listening, |
| networkError: translatedStrings.networkError, |
| noTranslation: translatedStrings.noTranslation, |
| noVoice: translatedStrings.noVoice, |
| otherError: translatedStrings.otherError, |
| permissionError: translatedStrings.permissionError, |
| ready: translatedStrings.ready, |
| tryAgain: translatedStrings.tryAgain, |
| waiting: translatedStrings.waiting, |
| }; |
| view.init(speech.onClick_); |
| speech.initWebkitSpeech_(); |
| speech.reset_(); |
| }; |
| |
| |
| /** |
| * Initializes and configures the speech recognition API. |
| * @private |
| */ |
| speech.initWebkitSpeech_ = function() { |
| speech.recognition_ = new webkitSpeechRecognition(); |
| speech.recognition_.continuous = false; |
| speech.recognition_.interimResults = true; |
| speech.recognition_.lang = getChromeUILanguage(); |
| speech.recognition_.onaudiostart = speech.handleRecognitionAudioStart_; |
| speech.recognition_.onend = speech.handleRecognitionEnd_; |
| speech.recognition_.onerror = speech.handleRecognitionError_; |
| speech.recognition_.onnomatch = speech.handleRecognitionOnNoMatch_; |
| speech.recognition_.onresult = speech.handleRecognitionResult_; |
| speech.recognition_.onspeechstart = speech.handleRecognitionSpeechStart_; |
| }; |
| |
| |
| /** |
| * Sets up the necessary states for voice search and then starts the |
| * speech recognition interface. |
| */ |
| speech.start = function() { |
| view.show(); |
| |
| speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); |
| |
| document.addEventListener( |
| 'webkitvisibilitychange', speech.onVisibilityChange_, false); |
| |
| // Initialize |speech.recognition_| if it isn't already. |
| if (!speech.recognition_) { |
| speech.initWebkitSpeech_(); |
| } |
| |
| // If |speech.start()| is called too soon after |speech.stop()| then the |
| // recognition interface hasn't yet reset and an error occurs. In this case |
| // we need to hard-reset it and reissue the |recognition_.start()| command. |
| try { |
| speech.recognition_.start(); |
| speech.currentState_ = speech.State_.STARTED; |
| } catch (error) { |
| speech.initWebkitSpeech_(); |
| try { |
| speech.recognition_.start(); |
| speech.currentState_ = speech.State_.STARTED; |
| } catch (error2) { |
| speech.stop(); |
| } |
| } |
| }; |
| |
| |
| /** |
| * Hides the overlay and resets the speech state. |
| */ |
| speech.stop = function() { |
| speech.recognition_.abort(); |
| speech.currentState_ = speech.State_.STOPPED; |
| view.hide(); |
| speech.reset_(); |
| }; |
| |
| |
| /** |
| * Resets the internal state to the READY state. |
| * @private |
| */ |
| speech.reset_ = function() { |
| window.clearTimeout(speech.idleTimer_); |
| window.clearTimeout(speech.errorTimer_); |
| |
| document.removeEventListener( |
| 'webkitvisibilitychange', speech.onVisibilityChange_, false); |
| |
| speech.interimResult_ = ''; |
| speech.finalResult_ = ''; |
| speech.currentState_ = speech.State_.READY; |
| }; |
| |
| |
| /** |
| * Informs the view that the browser is receiving audio input. |
| * @param {Event=} opt_event Emitted event for audio start. |
| * @private |
| */ |
| speech.handleRecognitionAudioStart_ = function(opt_event) { |
| speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); |
| speech.currentState_ = speech.State_.AUDIO_RECEIVED; |
| view.setReadyForSpeech(); |
| }; |
| |
| |
| /** |
| * Function is called when the user starts speaking. |
| * @param {Event=} opt_event Emitted event for speech start. |
| * @private |
| */ |
| speech.handleRecognitionSpeechStart_ = function(opt_event) { |
| speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); |
| speech.currentState_ = speech.State_.SPEECH_RECEIVED; |
| view.setReceivingSpeech(); |
| }; |
| |
| |
| /** |
| * Processes the recognition results arriving from the Web Speech API. |
| * @param {SpeechRecognitionEvent} responseEvent Event coming from the API. |
| * @private |
| */ |
| speech.handleRecognitionResult_ = function(responseEvent) { |
| speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); |
| |
| switch (speech.currentState_) { |
| case speech.State_.RESULT_RECEIVED: |
| case speech.State_.SPEECH_RECEIVED: |
| // Normal, expected states for processing results. |
| break; |
| case speech.State_.AUDIO_RECEIVED: |
| // Network bugginess (the onaudiostart packet was lost). |
| speech.handleRecognitionSpeechStart_(); |
| break; |
| case speech.State_.STARTED: |
| // Network bugginess (the onspeechstart packet was lost). |
| speech.handleRecognitionAudioStart_(); |
| speech.handleRecognitionSpeechStart_(); |
| break; |
| default: |
| // Not expecting results in any other states. |
| return; |
| } |
| |
| const results = responseEvent.results; |
| if (results.length == 0) { |
| return; |
| } |
| speech.currentState_ = speech.State_.RESULT_RECEIVED; |
| speech.interimResult_ = ''; |
| speech.finalResult_ = ''; |
| |
| const finalResult = results[responseEvent.resultIndex]; |
| // Process final results. |
| if (finalResult.isFinal) { |
| speech.finalResult_ = finalResult[0].transcript; |
| view.updateSpeechResult(speech.finalResult_, speech.finalResult_); |
| |
| speech.submitFinalResult_(); |
| return; |
| } |
| |
| // Process interim results. |
| for (let j = 0; j < results.length; j++) { |
| const result = results[j][0]; |
| speech.interimResult_ += result.transcript; |
| if (result.confidence > speech.RECOGNITION_CONFIDENCE_THRESHOLD_) { |
| speech.finalResult_ += result.transcript; |
| } |
| } |
| view.updateSpeechResult(speech.interimResult_, speech.finalResult_); |
| |
| // Force-stop long queries. |
| if (speech.interimResult_.length > speech.QUERY_LENGTH_LIMIT_) { |
| if (!!speech.finalResult_) { |
| speech.submitFinalResult_(); |
| } else { |
| speech.onErrorReceived_(RecognitionError.NO_MATCH); |
| } |
| } |
| }; |
| |
| |
| /** |
| * Handles state transition for the controller when an error occurs |
| * during speech recognition. |
| * @param {RecognitionError} error The appropriate error state from |
| * the RecognitionError enum. |
| * @private |
| */ |
| speech.onErrorReceived_ = function(error) { |
| speech.resetIdleTimer_(speech.IDLE_TIMEOUT_MS_); |
| speech.errorTimeoutMs_ = speech.getRecognitionErrorTimeout_(error); |
| if (error != RecognitionError.ABORTED) { |
| speech.currentState_ = speech.State_.ERROR_RECEIVED; |
| view.showError(error); |
| window.clearTimeout(speech.idleTimer_); |
| speech.resetErrorTimer_(speech.errorTimeoutMs_); |
| } |
| }; |
| |
| |
| /** |
| * Called when an error from Web Speech API is received. |
| * @param {SpeechRecognitionError} error The error event. |
| * @private |
| */ |
| speech.handleRecognitionError_ = function(error) { |
| speech.onErrorReceived_(speech.getRecognitionError_(error.error)); |
| }; |
| |
| |
| /** |
| * Stops speech recognition when no matches are found. |
| * @private |
| */ |
| speech.handleRecognitionOnNoMatch_ = function() { |
| speech.onErrorReceived_(RecognitionError.NO_MATCH); |
| }; |
| |
| |
| /** |
| * Stops the UI when the Web Speech API reports that it has halted speech |
| * recognition. |
| * @private |
| */ |
| speech.handleRecognitionEnd_ = function() { |
| window.clearTimeout(speech.idleTimer_); |
| |
| let error; |
| switch (speech.currentState_) { |
| case speech.State_.STARTED: |
| error = RecognitionError.AUDIO_CAPTURE; |
| break; |
| case speech.State_.AUDIO_RECEIVED: |
| error = RecognitionError.NO_SPEECH; |
| break; |
| case speech.State_.SPEECH_RECEIVED: |
| case speech.State_.RESULT_RECEIVED: |
| error = RecognitionError.NO_MATCH; |
| break; |
| case speech.State_.ERROR_RECEIVED: |
| error = RecognitionError.OTHER; |
| break; |
| default: |
| return; |
| } |
| |
| // If error has not yet been displayed. |
| if (speech.currentState_ != speech.State_.ERROR_RECEIVED) { |
| view.showError(error); |
| speech.resetErrorTimer_(speech.ERROR_TIMEOUT_LONG_MS_); |
| } |
| speech.currentState_ = speech.State_.STOPPED; |
| }; |
| |
| |
| /** |
| * Determines whether the user's browser is probably running on a Mac. |
| * @return {boolean} True iff the user's browser is running on a Mac. |
| * @private |
| */ |
| speech.isUserAgentMac_ = function() { |
| return window.navigator.userAgent.includes('Macintosh'); |
| }; |
| |
| |
| /** |
| * Handles the following keyboard actions. |
| * - <CTRL> + <SHIFT> + <.> starts voice input(<CMD> + <SHIFT> + <.> on mac). |
| * - <ESC> aborts voice input when the recognition interface is active. |
| * - <ENTER> submits the speech query if there is one. |
| * @param {KeyboardEvent} event The keydown event. |
| */ |
| speech.onKeyDown = function(event) { |
| if (!speech.isRecognizing_()) { |
| const ctrlKeyPressed = |
| event.ctrlKey || (speech.isUserAgentMac_() && event.metaKey); |
| if (speech.currentState_ == speech.State_.READY && |
| event.code == KEYCODE.PERIOD && event.shiftKey && ctrlKeyPressed) { |
| speech.start(); |
| } |
| } else { |
| // Ensures that keyboard events are not propagated during voice input. |
| event.stopPropagation(); |
| if (event.code == KEYCODE.ESC) { |
| speech.stop(); |
| } else if ( |
| (event.code == KEYCODE.ENTER || event.code == KEYCODE.NUMPAD_ENTER) && |
| speech.finalResult_) { |
| speech.submitFinalResult_(); |
| } |
| } |
| }; |
| |
| |
| /** |
| * Stops the recognition interface and closes the UI if no interactions occur |
| * after some time and the interface is still active. This is a safety net in |
| * case the recognition.onend event doesn't fire, as is sometime the case. If |
| * a high confidence transcription was received then show the search results. |
| * @private |
| */ |
| speech.onIdleTimeout_ = function() { |
| if (!!speech.finalResult_) { |
| speech.submitFinalResult_(); |
| return; |
| } |
| |
| switch (speech.currentState_) { |
| case speech.State_.STARTED: |
| case speech.State_.AUDIO_RECEIVED: |
| case speech.State_.SPEECH_RECEIVED: |
| case speech.State_.RESULT_RECEIVED: |
| case speech.State_.ERROR_RECEIVED: |
| speech.stop(); |
| break; |
| } |
| }; |
| |
| |
| /** |
| * Aborts the speech recognition interface when the user switches to a new |
| * tab or window. |
| * @private |
| */ |
| speech.onVisibilityChange_ = function() { |
| if (speech.isUiDefinitelyHidden_()) { |
| return; |
| } |
| |
| if (document.webkitHidden) { |
| speech.stop(); |
| } |
| }; |
| |
| |
| /** |
| * Aborts the speech session if the UI is showing and omnibox gets focused. |
| */ |
| speech.onOmniboxFocused = function() { |
| if (!speech.isUiDefinitelyHidden_()) { |
| speech.stop(); |
| } |
| }; |
| |
| |
| /** |
| * Change the location of this tab to the new URL. Used for query submission. |
| * @param {!URL} The URL to navigate to. |
| * @private |
| */ |
| speech.navigateToUrl_ = function(url) { |
| window.location.href = url.href; |
| }; |
| |
| |
| /** |
| * Submits the final spoken speech query to perform a search. |
| * @private |
| */ |
| speech.submitFinalResult_ = function() { |
| window.clearTimeout(speech.idleTimer_); |
| if (!speech.finalResult_) { |
| throw new Error('Submitting empty query.'); |
| } |
| |
| const searchParams = new URLSearchParams(); |
| // Add the encoded query. Getting |speech.finalResult_| needs to happen |
| // before stopping speech. |
| searchParams.append('q', speech.finalResult_); |
| // Add a parameter to indicate that this request is a voice search. |
| searchParams.append('gs_ivs', 1); |
| |
| // Build the query URL. |
| const queryUrl = new URL('/search', speech.googleBaseUrl_); |
| queryUrl.search = searchParams; |
| |
| speech.stop(); |
| speech.navigateToUrl_(queryUrl); |
| }; |
| |
| |
| /** |
| * Returns the error type based on the error string received from the webkit |
| * speech recognition API. |
| * @param {string} error The error string received from the webkit speech |
| * recognition API. |
| * @return {RecognitionError} The appropriate error state from |
| * the RecognitionError enum. |
| * @private |
| */ |
| speech.getRecognitionError_ = function(error) { |
| switch (error) { |
| case 'aborted': |
| return RecognitionError.ABORTED; |
| case 'audio-capture': |
| return RecognitionError.AUDIO_CAPTURE; |
| case 'bad-grammar': |
| return RecognitionError.BAD_GRAMMAR; |
| case 'language-not-supported': |
| return RecognitionError.LANGUAGE_NOT_SUPPORTED; |
| case 'network': |
| return RecognitionError.NETWORK; |
| case 'no-speech': |
| return RecognitionError.NO_SPEECH; |
| case 'not-allowed': |
| return RecognitionError.NOT_ALLOWED; |
| case 'service-not-allowed': |
| return RecognitionError.SERVICE_NOT_ALLOWED; |
| default: |
| return RecognitionError.OTHER; |
| } |
| }; |
| |
| /** |
| * Returns a timeout based on the error received from the webkit speech |
| * recognition API. |
| * @param {RecognitionError} error An error from the RecognitionError enum. |
| * @return {number} The appropriate timeout duration for displaying the error. |
| * @private |
| */ |
| speech.getRecognitionErrorTimeout_ = function(error) { |
| switch (error) { |
| case RecognitionError.AUDIO_CAPTURE: |
| case RecognitionError.NO_SPEECH: |
| case RecognitionError.NOT_ALLOWED: |
| case RecognitionError.SERVICE_NOT_ALLOWED: |
| case RecognitionError.NO_MATCH: |
| return speech.ERROR_TIMEOUT_LONG_MS_; |
| default: |
| return speech.ERROR_TIMEOUT_SHORT_MS_; |
| } |
| }; |
| |
| |
| /** |
| * Resets the idle state timeout. |
| * @param {number} duration The duration after which to close the UI. |
| * @private |
| */ |
| speech.resetIdleTimer_ = function(duration) { |
| window.clearTimeout(speech.idleTimer_); |
| speech.idleTimer_ = window.setTimeout(speech.onIdleTimeout_, duration); |
| }; |
| |
| |
| /** |
| * Resets the idle error state timeout. |
| * @param {number} duration The duration after which to close the UI during an |
| * error state. |
| * @private |
| */ |
| speech.resetErrorTimer_ = function(duration) { |
| window.clearTimeout(speech.errorTimer_); |
| speech.errorTimer_ = window.setTimeout(speech.stop, duration); |
| }; |
| |
| |
| /** |
| * Check to see if the speech recognition interface is running. |
| * @return {boolean} True, if the speech recognition interface is running. |
| * @private |
| */ |
| speech.isRecognizing_ = function() { |
| switch (speech.currentState_) { |
| case speech.State_.STARTED: |
| case speech.State_.AUDIO_RECEIVED: |
| case speech.State_.SPEECH_RECEIVED: |
| case speech.State_.RESULT_RECEIVED: |
| return true; |
| } |
| return false; |
| }; |
| |
| |
| /** |
| * Check if the controller is in a state where the UI is definitely hidden. |
| * Since we show the UI for a few seconds after we receive an error from the |
| * API, we need a separate definition to |speech.isRecognizing_()| to indicate |
| * when the UI is hidden. <strong>Note:</strong> that if this function |
| * returns false, it might not necessarily mean that the UI is visible. |
| * @return {boolean} True if the UI is hidden. |
| * @private |
| */ |
| speech.isUiDefinitelyHidden_ = function() { |
| switch (speech.currentState_) { |
| case speech.State_.READY: |
| case speech.State_.UNINITIALIZED: |
| return true; |
| } |
| return false; |
| }; |
| |
| |
| /** |
| * Handles click events during speech recognition. |
| * @param {boolean} shouldSubmit True if a query should be submitted. |
| * @param {boolean} shouldRetry True if the interface should be restarted. |
| * @param {boolean} navigatingAway True if the browser is navigating away |
| * from the NTP. |
| * @private |
| */ |
| speech.onClick_ = function(shouldSubmit, shouldRetry, navigatingAway) { |
| if (speech.finalResult_ && shouldSubmit) { |
| speech.submitFinalResult_(); |
| } else if (speech.currentState_ == speech.State_.STOPPED && shouldRetry) { |
| speech.reset_(); |
| speech.start(); |
| } else if (speech.currentState_ == speech.State_.STOPPED && navigatingAway) { |
| // If the user clicks on a "Learn more" or "Details" support page link |
| // from an error message, do nothing, and let Chrome navigate to that page. |
| } else { |
| speech.stop(); |
| } |
| }; |
| |
| |
| /* TEXT VIEW */ |
| /** |
| * Provides methods for styling and animating the text areas |
| * left of the microphone button. |
| */ |
| let text = {}; |
| |
| |
| /** |
| * ID for the "Try Again" link shown in error output. |
| * @const |
| */ |
| text.RETRY_LINK_ID = 'voice-retry-link'; |
| |
| |
| /** |
| * ID for the Voice Search support site link shown in error output. |
| * @const |
| */ |
| text.SUPPORT_LINK_ID = 'voice-support-link'; |
| |
| |
| /** |
| * Class for the links shown in error output. |
| * @const @private |
| */ |
| text.ERROR_LINK_CLASS_ = 'voice-text-link'; |
| |
| |
| /** |
| * Class name for the speech recognition result output area. |
| * @const @private |
| */ |
| text.TEXT_AREA_CLASS_ = 'voice-text'; |
| |
| /** |
| * Class name for the "Listening..." text animation. |
| * @const @private |
| */ |
| text.LISTENING_ANIMATION_CLASS_ = 'listening-animation'; |
| |
| |
| /** |
| * ID of the final / high confidence speech recognition results element. |
| * @const @private |
| */ |
| text.FINAL_TEXT_AREA_ID_ = 'voice-text-f'; |
| |
| |
| /** |
| * ID of the interim / low confidence speech recognition results element. |
| * @const @private |
| */ |
| text.INTERIM_TEXT_AREA_ID_ = 'voice-text-i'; |
| |
| |
| /** |
| * The line height of the speech recognition results text. |
| * @const @private |
| */ |
| text.LINE_HEIGHT_ = 1.2; |
| |
| |
| /** |
| * Font size in the full page view in pixels. |
| * @const @private |
| */ |
| text.FONT_SIZE_ = 32; |
| |
| |
| /** |
| * Delay in milliseconds before showing the initializing message. |
| * @const @private |
| */ |
| text.INITIALIZING_TIMEOUT_MS_ = 300; |
| |
| |
| /** |
| * Delay in milliseconds before showing the listening message. |
| * @const @private |
| */ |
| text.LISTENING_TIMEOUT_MS_ = 2000; |
| |
| |
| /** |
| * Base link target for help regarding voice search. To be appended |
| * with a locale string for proper target site localization. |
| * @const @private |
| */ |
| text.SUPPORT_LINK_BASE_ = |
| 'https://support.google.com/chrome/?p=ui_voice_search&hl='; |
| |
| |
| /** |
| * The final / high confidence speech recognition result element. |
| * @private {Element} |
| */ |
| text.final_; |
| |
| |
| /** |
| * The interim / low confidence speech recognition result element. |
| * @private {Element} |
| */ |
| text.interim_; |
| |
| |
| /** |
| * Stores the ID of the initializing message timer. |
| * @private {number} |
| */ |
| text.initializingTimer_; |
| |
| |
| /** |
| * Stores the ID of the listening message timer. |
| * @private {number} |
| */ |
| text.listeningTimer_; |
| |
| |
| /** |
| * Finds the text view elements. |
| */ |
| text.init = function() { |
| text.final_ = $(text.FINAL_TEXT_AREA_ID_); |
| text.interim_ = $(text.INTERIM_TEXT_AREA_ID_); |
| text.clear(); |
| }; |
| |
| |
| /** |
| * Updates the text elements with new recognition results. |
| * @param {!string} interimText Low confidence speech recognition result text. |
| * @param {!string} opt_finalText High confidence speech recognition result |
| * text, defaults to an empty string. |
| */ |
| text.updateTextArea = function(interimText, opt_finalText = '') { |
| window.clearTimeout(text.initializingTimer_); |
| text.clearListeningTimeout(); |
| |
| text.interim_.textContent = interimText; |
| text.final_.textContent = opt_finalText; |
| |
| text.interim_.className = text.final_.className = text.getTextClassName_(); |
| }; |
| |
| |
| /** |
| * Sets the text view to the initializing state. The initializing message |
| * shown while waiting for permission is not displayed immediately, but after |
| * a short timeout. The reason for this is that the "Waiting..." message would |
| * still appear ("blink") every time a user opens Voice Search, even if they |
| * have already granted and persisted microphone permission for the NTP, |
| * and could therefore directly proceed to the "Speak now" message. |
| */ |
| text.showInitializingMessage = function() { |
| text.interim_.textContent = ''; |
| text.final_.textContent = ''; |
| |
| const displayMessage = function() { |
| if (text.interim_.textContent == '') { |
| text.updateTextArea(speech.messages.waiting); |
| } |
| }; |
| text.initializingTimer_ = |
| window.setTimeout(displayMessage, text.INITIALIZING_TIMEOUT_MS_); |
| }; |
| |
| |
| /** |
| * Sets the text view to the ready state. |
| */ |
| text.showReadyMessage = function() { |
| window.clearTimeout(text.initializingTimer_); |
| text.clearListeningTimeout(); |
| text.updateTextArea(speech.messages.ready); |
| text.startListeningMessageAnimation_(); |
| }; |
| |
| |
| /** |
| * Display an error message in the text area for the given error. |
| * @param {RecognitionError} error The error that occured. |
| */ |
| text.showErrorMessage = function(error) { |
| text.updateTextArea(text.getErrorMessage_(error)); |
| |
| const linkElement = text.getErrorLink_(error); |
| // Setting textContent removes all children (no need to clear link elements). |
| if (!!linkElement) { |
| text.interim_.textContent += ' '; |
| text.interim_.appendChild(linkElement); |
| } |
| }; |
| |
| |
| /** |
| * Returns an error message based on the error. |
| * @param {RecognitionError} error The error that occured. |
| * @private |
| */ |
| text.getErrorMessage_ = function(error) { |
| switch (error) { |
| case RecognitionError.NO_MATCH: |
| return speech.messages.noTranslation; |
| case RecognitionError.NO_SPEECH: |
| return speech.messages.noVoice; |
| case RecognitionError.AUDIO_CAPTURE: |
| return speech.messages.audioError; |
| case RecognitionError.NETWORK: |
| return speech.messages.networkError; |
| case RecognitionError.NOT_ALLOWED: |
| case RecognitionError.SERVICE_NOT_ALLOWED: |
| return speech.messages.permissionError; |
| case RecognitionError.LANGUAGE_NOT_SUPPORTED: |
| return speech.messages.languageError; |
| default: |
| return speech.messages.otherError; |
| } |
| }; |
| |
| |
| /** |
| * Returns an error message help link based on the error. |
| * @param {RecognitionError} error The error that occured. |
| * @private |
| */ |
| text.getErrorLink_ = function(error) { |
| let linkElement = document.createElement('a'); |
| linkElement.className = text.ERROR_LINK_CLASS_; |
| |
| switch (error) { |
| case RecognitionError.NO_MATCH: |
| linkElement.id = text.RETRY_LINK_ID; |
| linkElement.textContent = speech.messages.tryAgain; |
| // When clicked, |view.onWindowClick_| gets called. |
| return linkElement; |
| case RecognitionError.NO_SPEECH: |
| case RecognitionError.AUDIO_CAPTURE: |
| linkElement.id = text.SUPPORT_LINK_ID; |
| linkElement.href = text.SUPPORT_LINK_BASE_ + getChromeUILanguage(); |
| linkElement.textContent = speech.messages.learnMore; |
| linkElement.target = '_blank'; |
| return linkElement; |
| case RecognitionError.NOT_ALLOWED: |
| case RecognitionError.SERVICE_NOT_ALLOWED: |
| linkElement.id = text.SUPPORT_LINK_ID; |
| linkElement.href = text.SUPPORT_LINK_BASE_ + getChromeUILanguage(); |
| linkElement.textContent = speech.messages.details; |
| linkElement.target = '_blank'; |
| return linkElement; |
| default: |
| return null; |
| } |
| }; |
| |
| |
| /** |
| * Clears the text elements. |
| */ |
| text.clear = function() { |
| text.updateTextArea(''); |
| |
| text.clearListeningTimeout(); |
| window.clearTimeout(text.initializingTimer_); |
| |
| text.interim_.className = text.TEXT_AREA_CLASS_; |
| text.final_.className = text.TEXT_AREA_CLASS_; |
| }; |
| |
| |
| /** |
| * Cancels listening message display. |
| */ |
| text.clearListeningTimeout = function() { |
| window.clearTimeout(text.listeningTimer_); |
| }; |
| |
| |
| /** |
| * Determines the class name of the text output Elements. |
| * @return {string} The class name. |
| * @private |
| */ |
| text.getTextClassName_ = function() { |
| // Shift up for every line. |
| const oneLineHeight = text.LINE_HEIGHT_ * text.FONT_SIZE_ + 1; |
| const twoLineHeight = text.LINE_HEIGHT_ * text.FONT_SIZE_ * 2 + 1; |
| const threeLineHeight = text.LINE_HEIGHT_ * text.FONT_SIZE_ * 3 + 1; |
| const fourLineHeight = text.LINE_HEIGHT_ * text.FONT_SIZE_ * 4 + 1; |
| |
| const height = text.interim_.scrollHeight; |
| let className = text.TEXT_AREA_CLASS_; |
| |
| if (height > fourLineHeight) { |
| className += ' voice-text-5l'; |
| } else if (height > threeLineHeight) { |
| className += ' voice-text-4l'; |
| } else if (height > twoLineHeight) { |
| className += ' voice-text-3l'; |
| } else if (height > oneLineHeight) { |
| className += ' voice-text-2l'; |
| } |
| return className; |
| }; |
| |
| |
| /** |
| * Displays the listening message animation after the ready message has been |
| * shown for |text.LISTENING_TIMEOUT_MS_| milliseconds without further user |
| * action. |
| * @private |
| */ |
| text.startListeningMessageAnimation_ = function() { |
| const animateListeningText = function() { |
| // TODO(oskopek): Substitute the fragile string comparison with a correct |
| // state condition. |
| if (text.interim_.textContent == speech.messages.ready) { |
| text.updateTextArea(speech.messages.listening); |
| text.interim_.classList.add(text.LISTENING_ANIMATION_CLASS_); |
| } |
| }; |
| |
| text.listeningTimer_ = |
| window.setTimeout(animateListeningText, text.LISTENING_TIMEOUT_MS_); |
| }; |
| /* END TEXT VIEW */ |
| |
| |
| /* MICROPHONE VIEW */ |
| /** |
| * Provides methods for animating the microphone button and icon |
| * on the Voice Search full screen overlay. |
| */ |
| let microphone = {}; |
| |
| |
| /** |
| * ID for the button Element. |
| * @const |
| */ |
| microphone.RED_BUTTON_ID = 'voice-button'; |
| |
| |
| /** |
| * ID for the level animations Element that indicates input volume. |
| * @const @private |
| */ |
| microphone.LEVEL_ID_ = 'voice-level'; |
| |
| |
| /** |
| * ID for the container of the microphone, red button and level animations. |
| * @const @private |
| */ |
| microphone.CONTAINER_ID_ = 'voice-button-container'; |
| |
| |
| /** |
| * The minimum transform scale for the volume rings. |
| * @const @private |
| */ |
| microphone.LEVEL_SCALE_MINIMUM_ = 0.5; |
| |
| |
| /** |
| * The range of the transform scale for the volume rings. |
| * @const @private |
| */ |
| microphone.LEVEL_SCALE_RANGE_ = 0.55; |
| |
| |
| /** |
| * The minimum transition time (in milliseconds) for the volume rings. |
| * @const @private |
| */ |
| microphone.LEVEL_TIME_STEP_MINIMUM_ = 170; |
| |
| |
| /** |
| * The range of the transition time for the volume rings. |
| * @const @private |
| */ |
| microphone.LEVEL_TIME_STEP_RANGE_ = 10; |
| |
| |
| /** |
| * The button with the microphone icon. |
| * @private {Element} |
| */ |
| microphone.button_; |
| |
| |
| /** |
| * The voice level element that is displayed when the user starts speaking. |
| * @private {Element} |
| */ |
| microphone.level_; |
| |
| |
| /** |
| * Variable to indicate whether level animations are underway. |
| * @private {boolean} |
| */ |
| microphone.isLevelAnimating_ = false; |
| |
| |
| /** |
| * Creates/finds the output elements for the microphone rendering and animation. |
| */ |
| microphone.init = function() { |
| // Get the button element and microphone container. |
| microphone.button_ = $(microphone.RED_BUTTON_ID); |
| |
| // Get the animation elements. |
| microphone.level_ = $(microphone.LEVEL_ID_); |
| }; |
| |
| |
| /** |
| * Starts the volume circles animations, if it has not started yet. |
| */ |
| microphone.startInputAnimation = function() { |
| if (!microphone.isLevelAnimating_) { |
| microphone.isLevelAnimating_ = true; |
| microphone.runLevelAnimation_(); |
| } |
| }; |
| |
| |
| /** |
| * Stops the volume circles animations. |
| */ |
| microphone.stopInputAnimation = function() { |
| microphone.isLevelAnimating_ = false; |
| }; |
| |
| |
| /** |
| * Runs the volume level animation. |
| * @private |
| */ |
| microphone.runLevelAnimation_ = function() { |
| if (!microphone.isLevelAnimating_) { |
| microphone.level_.style.removeProperty('opacity'); |
| microphone.level_.style.removeProperty('transition'); |
| microphone.level_.style.removeProperty('transform'); |
| return; |
| } |
| const scale = microphone.LEVEL_SCALE_MINIMUM_ + |
| Math.random() * microphone.LEVEL_SCALE_RANGE_; |
| const timeStep = Math.round( |
| microphone.LEVEL_TIME_STEP_MINIMUM_ + |
| Math.random() * microphone.LEVEL_TIME_STEP_RANGE_); |
| microphone.level_.style.setProperty( |
| 'transition', 'transform ' + timeStep + 'ms ease-in-out'); |
| microphone.level_.style.setProperty('transform', 'scale(' + scale + ')'); |
| window.setTimeout(microphone.runLevelAnimation_, timeStep); |
| }; |
| /* END MICROPHONE VIEW */ |
| |
| |
| /* VIEW */ |
| /** |
| * Provides methods for manipulating and animating the Voice Search |
| * full screen overlay. |
| */ |
| let view = {}; |
| |
| |
| /** |
| * Class name of the speech recognition interface on the homepage. |
| * @const @private |
| */ |
| view.OVERLAY_CLASS_ = 'overlay'; |
| |
| |
| /** |
| * Class name of the speech recognition interface when it is hidden on the |
| * homepage. |
| * @const @private |
| */ |
| view.OVERLAY_HIDDEN_CLASS_ = 'overlay-hidden'; |
| |
| |
| /** |
| * ID for the speech output background. |
| * @const @private |
| */ |
| view.BACKGROUND_ID_ = 'voice-overlay'; |
| |
| |
| /** |
| * ID for the speech output container. |
| * @const @private |
| */ |
| view.CONTAINER_ID_ = 'voice-outer'; |
| |
| |
| /** |
| * Class name used to modify the UI to the 'listening' state. |
| * @const @private |
| */ |
| view.MICROPHONE_LISTENING_CLASS_ = 'outer voice-ml'; |
| |
| |
| /** |
| * Class name used to modify the UI to the 'receiving speech' state. |
| * @const @private |
| */ |
| view.RECEIVING_SPEECH_CLASS_ = 'outer voice-rs'; |
| |
| |
| /** |
| * Class name used to modify the UI to the 'error received' state. |
| * @const @private |
| */ |
| view.ERROR_RECEIVED_CLASS_ = 'outer voice-er'; |
| |
| |
| /** |
| * Class name used to modify the UI to the inactive state. |
| * @const @private |
| */ |
| view.INACTIVE_CLASS_ = 'outer'; |
| |
| |
| /** |
| * Background element and container of all other elements. |
| * @private {Element} |
| */ |
| view.background_; |
| |
| |
| /** |
| * The container used to position the microphone and text output area. |
| * @private {Element} |
| */ |
| view.container_; |
| |
| |
| /** |
| * True if the the last error message shown was for the 'no-match' error. |
| * @private {boolean} |
| */ |
| view.isNoMatchShown_ = false; |
| |
| |
| /** |
| * True if the UI elements are visible. |
| * @private {boolean} |
| */ |
| view.isVisible_ = false; |
| |
| |
| /** |
| * The function to call when there is a click event. |
| * @private {Function} |
| */ |
| view.onClick_; |
| |
| |
| /** |
| * Displays the UI. |
| */ |
| view.show = function() { |
| if (!view.isVisible_) { |
| text.showInitializingMessage(); |
| view.showView_(); |
| window.addEventListener('mouseup', view.onWindowClick_, false); |
| } |
| }; |
| |
| /** |
| * Sets the output area text to listening. This should only be called when |
| * the Web Speech API starts receiving audio input (i.e., onaudiostart). |
| */ |
| view.setReadyForSpeech = function() { |
| if (view.isVisible_) { |
| view.container_.className = view.MICROPHONE_LISTENING_CLASS_; |
| text.showReadyMessage(); |
| } |
| }; |
| |
| |
| /** |
| * Shows the pulsing animation emanating from the microphone. This should only |
| * be called when the Web Speech API starts receiving speech input (i.e., |
| * |onspeechstart|). Do note that this may also be run when the Web Speech API |
| * is receiving speech recognition results (|onresult|), because |onspeechstart| |
| * may not have been called. |
| */ |
| view.setReceivingSpeech = function() { |
| if (view.isVisible_) { |
| view.container_.className = view.RECEIVING_SPEECH_CLASS_; |
| microphone.startInputAnimation(); |
| text.clearListeningTimeout(); |
| } |
| }; |
| |
| |
| /** |
| * Updates the speech recognition results output with the latest results. |
| * @param {string} interimResultText Low confidence recognition text (grey). |
| * @param {string} finalResultText High confidence recognition text (black). |
| */ |
| view.updateSpeechResult = function(interimResultText, finalResultText) { |
| if (view.isVisible_) { |
| // If the Web Speech API is receiving speech recognition results |
| // (|onresult|) and |onspeechstart| has not been called. |
| if (view.container_.className != view.RECEIVING_SPEECH_CLASS_) { |
| view.setReceivingSpeech(); |
| } |
| text.updateTextArea(interimResultText, finalResultText); |
| } |
| }; |
| |
| |
| /** |
| * Hides the UI and stops animations. |
| */ |
| view.hide = function() { |
| window.removeEventListener('mouseup', view.onWindowClick_, false); |
| view.stopMicrophoneAnimations_(); |
| view.hideView_(); |
| view.isNoMatchShown_ = false; |
| text.clear(); |
| }; |
| |
| |
| /** |
| * Find the page elements that will be used to render the speech recognition |
| * interface area. |
| * @param {Function} onClick The function to call when there is a click event |
| * in the window. |
| */ |
| view.init = function(onClick) { |
| view.onClick_ = onClick; |
| |
| view.background_ = $(view.BACKGROUND_ID_); |
| view.container_ = $(view.CONTAINER_ID_); |
| |
| text.init(); |
| microphone.init(); |
| }; |
| |
| |
| /** |
| * Displays an error message and stops animations. |
| * @param {RecognitionError} error The error type. |
| */ |
| view.showError = function(error) { |
| view.container_.className = view.ERROR_RECEIVED_CLASS_; |
| text.showErrorMessage(error); |
| view.stopMicrophoneAnimations_(); |
| view.isNoMatchShown_ = (error == RecognitionError.NO_MATCH); |
| }; |
| |
| |
| /** |
| * Makes the view visible. |
| * @private |
| */ |
| view.showView_ = function() { |
| if (!view.isVisible_) { |
| view.background_.hidden = false; |
| view.showFullPage_(); |
| view.isVisible_ = true; |
| } |
| }; |
| |
| |
| /** |
| * Displays the full page view, animating from the hidden state to the visible |
| * state. |
| * @private |
| */ |
| view.showFullPage_ = function() { |
| view.background_.className = view.OVERLAY_HIDDEN_CLASS_; |
| view.background_.className = view.OVERLAY_CLASS_; |
| }; |
| |
| |
| /** |
| * Hides the view. |
| * @private |
| */ |
| view.hideView_ = function() { |
| view.background_.className = view.OVERLAY_HIDDEN_CLASS_; |
| view.container_.className = view.INACTIVE_CLASS_; |
| view.background_.removeAttribute('style'); |
| view.background_.hidden = true; |
| view.isVisible_ = false; |
| }; |
| |
| |
| /** |
| * Stops the animations in the microphone view. |
| * @private |
| */ |
| view.stopMicrophoneAnimations_ = function() { |
| microphone.stopInputAnimation(); |
| }; |
| |
| |
| /** |
| * Makes sure that a click anywhere closes the UI when it is active. |
| * @param {Event} event The click event. |
| * @private |
| */ |
| view.onWindowClick_ = function(event) { |
| if (!view.isVisible_) { |
| return; |
| } |
| const retryLinkClicked = event.target.id === text.RETRY_LINK_ID; |
| const supportLinkClicked = event.target.id === text.SUPPORT_LINK_ID; |
| const micIconClicked = event.target.id === microphone.RED_BUTTON_ID; |
| |
| const submitQuery = micIconClicked && !view.isNoMatchShown_; |
| const shouldRetry = |
| retryLinkClicked || (micIconClicked && view.isNoMatchShown_); |
| const navigatingAway = supportLinkClicked; |
| view.onClick_(submitQuery, shouldRetry, navigatingAway); |
| }; |
| /* END VIEW */ |