blob: 2e0592c3da93823e1620d5699e6fa841804163ad [file] [log] [blame]
// 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 */