blob: 884cf7ca391568ce9b10aa24d21d7ad2323a2535 [file] [log] [blame]
// Copyright 2020 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.
import 'chrome://resources/polymer/v3_0/iron-pages/iron-pages.js';
import 'chrome://resources/polymer/v3_0/iron-selector/iron-selector.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.m.js';
import {html, mixinBehaviors, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {I18nBehavior, loadTimeData} from './i18n_setup.js';
import {NewTabPageProxy} from './new_tab_page_proxy.js';
import {WindowProxy} from './window_proxy.js';
/**
* 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).
* @type {number}
*/
const RECOGNITION_CONFIDENCE_THRESHOLD = 0.5;
/**
* Maximum number of characters recognized before force-submitting a query.
* Includes characters of non-confident recognition transcripts.
* @type {number}
*/
const QUERY_LENGTH_LIMIT = 120;
/**
* Time in milliseconds to wait before closing the UI if no interaction has
* occurred.
* @type {number}
*/
const IDLE_TIMEOUT_MS = 8000;
/**
* Time in milliseconds to wait before closing the UI after an error has
* occurred. This is a short timeout used when no click-target is present.
* @type {number}
*/
const ERROR_TIMEOUT_SHORT_MS = 9000;
/**
* Time in milliseconds to wait before closing the UI after an error has
* occurred. This is a longer timeout used when there is a click-target is
* present.
* @type {number}
*/
const ERROR_TIMEOUT_LONG_MS = 24000;
/**
* The minimum transition time for the volume rings.
* @private
*/
const VOLUME_ANIMATION_DURATION_MIN_MS = 170;
/**
* The range of the transition time for the volume rings.
* @private
*/
const VOLUME_ANIMATION_DURATION_RANGE_MS = 10;
/**
* The set of controller states.
* @enum {number}
* @private
*/
const State = {
// Initial state before voice recognition has been set up.
UNINITIALIZED: -1,
// Indicates that speech recognition has started, but no audio has yet
// been captured.
STARTED: 0,
// Indicates that audio is being captured by the Web Speech API, but no
// speech has yet been recognized. UI indicates that audio is being captured.
AUDIO_RECEIVED: 1,
// Indicates that speech has been recognized by the Web Speech API, but no
// resulting transcripts have yet been received back. UI indicates that audio
// is being captured and is pulsating audio button.
SPEECH_RECEIVED: 2,
// Indicates speech has been successfully recognized and text transcripts have
// been reported back. UI indicates that audio is being captured and is
// displaying transcripts received so far.
RESULT_RECEIVED: 3,
// 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. UI displays the error message.
ERROR_RECEIVED: 4,
// Indicates speech recognition has received a final search query but the UI
// has not yet redirected. The UI is displaying the final query.
RESULT_FINAL: 5,
};
/**
* Action the user can perform while using voice search. This enum must match
* the numbering for NewTabPageVoiceAction in enums.xml. These values are
* persisted to logs. Entries should not be renumbered, removed or reused.
* @enum {number}
*/
export const Action = {
kActivateSearchBox: 0,
kActivateKeyboard: 1,
kCloseOverlay: 2,
kQuerySubmitted: 3,
kSupportLinkClicked: 4,
kTryAgainLink: 5,
kTryAgainMicButton: 6,
};
/**
* Errors than can occur while using voice search. This enum must match the
* numbering for NewTabPageVoiceError in enums.xml. These values are persisted
* to logs. Entries should not be renumbered, removed or reused.
* @enum {number}
*/
export const Error = {
kAborted: 0,
kAudioCapture: 1,
kBadGrammar: 2,
kLanguageNotSupported: 3,
kNetwork: 4,
kNoMatch: 5,
kNoSpeech: 6,
kNotAllowed: 7,
kOther: 8,
kServiceNotAllowed: 9,
};
/** @param {!Action} action */
export function recordVoiceAction(action) {
chrome.metricsPrivate.recordEnumerationValue(
'NewTabPage.VoiceActions', action, Object.keys(Action).length);
}
/**
* Returns the error type based on the error string received from the webkit
* speech recognition API.
* @param {string} webkitError The error string received from the webkit speech
* recognition API.
* @return {!Error} The appropriate error state from the Error enum.
*/
function toError(webkitError) {
switch (webkitError) {
case 'aborted':
return Error.kAborted;
case 'audio-capture':
return Error.kAudioCapture;
case 'language-not-supported':
return Error.kLanguageNotSupported;
case 'network':
return Error.kNetwork;
case 'no-speech':
return Error.kNoSpeech;
case 'not-allowed':
return Error.kNotAllowed;
case 'service-not-allowed':
return Error.kServiceNotAllowed;
case 'bad-grammar':
return Error.kBadGrammar;
default:
return Error.kOther;
}
}
/**
* Returns a timeout based on the error received from the webkit speech
* recognition API.
* @param {Error} error An error from the Error enum.
* @return {number} The appropriate timeout in MS for displaying the error.
*/
function getErrorTimeout(error) {
switch (error) {
case Error.kAudioCapture:
case Error.kNoSpeech:
case Error.kNotAllowed:
case Error.kNoMatch:
return ERROR_TIMEOUT_LONG_MS;
default:
return ERROR_TIMEOUT_SHORT_MS;
}
}
/**
* Overlay that lats the user perform voice searches.
* @polymer
* @extends {PolymerElement}
*/
class VoiceSearchOverlayElement extends mixinBehaviors
([I18nBehavior], PolymerElement) {
static get is() {
return 'ntp-voice-search-overlay';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
/** @private */
interimResult_: String,
/** @private */
finalResult_: String,
/** @private */
state_: {
type: Number,
value: State.UNINITIALIZED,
},
/** @private */
error_: Number,
/** @private */
helpUrl_: {
type: String,
readOnly: true,
value: `https://support.google.com/chrome/?` +
`p=ui_voice_search&hl=${window.navigator.language}`,
},
/** @private */
micVolumeLevel_: {
type: Number,
value: 0,
},
/** @private */
micVolumeDuration_: {
type: Number,
value: VOLUME_ANIMATION_DURATION_MIN_MS,
},
};
}
constructor() {
super();
/** @private {newTabPage.mojom.PageHandlerRemote} */
this.pageHandler_ = NewTabPageProxy.getInstance().handler;
/** @private {webkitSpeechRecognition} */
this.voiceRecognition_ = new webkitSpeechRecognition();
this.voiceRecognition_.continuous = false;
this.voiceRecognition_.interimResults = true;
this.voiceRecognition_.lang = window.navigator.language;
this.voiceRecognition_.onaudiostart = this.onAudioStart_.bind(this);
this.voiceRecognition_.onspeechstart = this.onSpeechStart_.bind(this);
this.voiceRecognition_.onresult = this.onResult_.bind(this);
this.voiceRecognition_.onend = this.onEnd_.bind(this);
this.voiceRecognition_.onerror = (e) => {
this.onError_(toError(e.error));
};
this.voiceRecognition_.onnomatch = () => {
this.onError_(Error.kNoMatch);
};
/** @private {?number} */
this.timerId_ = null;
}
/** @override */
connectedCallback() {
super.connectedCallback();
this.$.dialog.showModal();
this.start();
}
/** @private */
start() {
this.voiceRecognition_.start();
this.state_ = State.STARTED;
this.resetIdleTimer_();
}
/** @private */
onOverlayClose_() {
this.voiceRecognition_.abort();
this.dispatchEvent(new Event('close'));
}
/** @private */
onOverlayClick_() {
this.$.dialog.close();
recordVoiceAction(Action.kCloseOverlay);
}
/**
* Handles <ENTER> or <SPACE> to trigger a query if we have recognized speech.
* @param {KeyboardEvent} e
* @private
*/
onOverlayKeydown_(e) {
if (['Enter', ' '].includes(e.key) && this.finalResult_) {
this.onFinalResult_();
} else if (e.key === 'Escape') {
this.onOverlayClick_();
}
}
/**
* Handles <ENTER> or <SPACE> to simulate click.
* @param {KeyboardEvent} e
* @private
*/
onLinkKeydown_(e) {
if (!['Enter', ' '].includes(e.key)) {
return;
}
// Otherwise, we may trigger overlay-wide keyboard shortcuts.
e.stopPropagation();
// Otherwise, we open the link twice.
e.preventDefault();
e.target.click();
}
/** @private */
onLearnMoreClick_() {
recordVoiceAction(Action.kSupportLinkClicked);
}
/**
* @param {!Event} e
* @private
*/
onTryAgainClick_(e) {
// Otherwise, we close the overlay.
e.stopPropagation();
this.start();
recordVoiceAction(Action.kTryAgainLink);
}
/**
* @param {!Event} e
* @private
*/
onMicClick_(e) {
if (this.state_ !== State.ERROR_RECEIVED ||
this.error_ !== Error.kNoMatch) {
return;
}
// Otherwise, we close the overlay.
e.stopPropagation();
this.start();
recordVoiceAction(Action.kTryAgainMicButton);
}
/** @private */
resetIdleTimer_() {
WindowProxy.getInstance().clearTimeout(this.timerId_);
this.timerId_ = WindowProxy.getInstance().setTimeout(
this.onIdleTimeout_.bind(this), IDLE_TIMEOUT_MS);
}
/** @private */
onIdleTimeout_() {
if (this.state_ === State.RESULT_FINAL) {
// Waiting for query redirect.
return;
}
if (this.finalResult_) {
// Query what we recognized so far.
this.onFinalResult_();
return;
}
this.voiceRecognition_.abort();
this.onError_(Error.kNoMatch);
}
/**
* @param {number} duration
* @private
*/
resetErrorTimer_(duration) {
WindowProxy.getInstance().clearTimeout(this.timerId_);
this.timerId_ = WindowProxy.getInstance().setTimeout(() => {
this.$.dialog.close();
}, duration);
}
/** @private */
onAudioStart_() {
this.resetIdleTimer_();
this.state_ = State.AUDIO_RECEIVED;
}
/** @private */
onSpeechStart_() {
this.resetIdleTimer_();
this.state_ = State.SPEECH_RECEIVED;
this.animateVolume_();
}
/**
* @param {SpeechRecognitionEvent} e
* @private
*/
onResult_(e) {
this.resetIdleTimer_();
switch (this.state_) {
case State.STARTED:
// Network bugginess (the onspeechstart packet was lost).
this.onAudioStart_();
this.onSpeechStart_();
break;
case State.AUDIO_RECEIVED:
// Network bugginess (the onaudiostart packet was lost).
this.onSpeechStart_();
break;
case State.SPEECH_RECEIVED:
case State.RESULT_RECEIVED:
// Normal, expected states for processing results.
break;
default:
// Not expecting results in any other states.
return;
}
const results = e.results;
if (results.length === 0) {
return;
}
this.state_ = State.RESULT_RECEIVED;
this.interimResult_ = '';
this.finalResult_ = '';
const finalResult = results[e.resultIndex];
// Process final results.
if (finalResult.isFinal) {
this.finalResult_ = finalResult[0].transcript;
this.onFinalResult_();
return;
}
// Process interim results.
for (let j = 0; j < results.length; j++) {
const result = results[j][0];
if (result.confidence > RECOGNITION_CONFIDENCE_THRESHOLD) {
this.finalResult_ += result.transcript;
} else {
this.interimResult_ += result.transcript;
}
}
// Force-stop long queries.
if (this.interimResult_.length > QUERY_LENGTH_LIMIT) {
this.onFinalResult_();
}
}
/** @private */
onFinalResult_() {
if (!this.finalResult_) {
this.onError_(Error.kNoMatch);
return;
}
this.state_ = State.RESULT_FINAL;
const searchParams = new URLSearchParams();
searchParams.append('q', this.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', loadTimeData.getString('googleBaseUrl'));
queryUrl.search = searchParams.toString();
recordVoiceAction(Action.kQuerySubmitted);
WindowProxy.getInstance().navigate(queryUrl.href);
}
/** @private */
onEnd_() {
switch (this.state_) {
case State.STARTED:
this.onError_(Error.kAudioCapture);
return;
case State.AUDIO_RECEIVED:
this.onError_(Error.kNoSpeech);
return;
case State.SPEECH_RECEIVED:
case State.RESULT_RECEIVED:
this.onError_(Error.kNoMatch);
return;
case State.ERROR_RECEIVED:
case State.RESULT_FINAL:
return;
default:
this.onError_(Error.kOther);
return;
}
}
/**
* @param {Error} error
* @private
*/
onError_(error) {
chrome.metricsPrivate.recordEnumerationValue(
'NewTabPage.VoiceErrors', error, Object.keys(Error).length);
if (error === Error.kAborted) {
// We are in the process of closing voice search.
return;
}
this.error_ = error;
this.state_ = State.ERROR_RECEIVED;
this.resetErrorTimer_(getErrorTimeout(error));
}
/** @private */
animateVolume_() {
this.micVolumeLevel_ = 0;
this.micVolumeDuration_ = VOLUME_ANIMATION_DURATION_MIN_MS;
if (this.state_ !== State.SPEECH_RECEIVED &&
this.state_ !== State.RESULT_RECEIVED) {
return;
}
this.micVolumeLevel_ = WindowProxy.getInstance().random();
this.micVolumeDuration_ = Math.round(
VOLUME_ANIMATION_DURATION_MIN_MS +
WindowProxy.getInstance().random() *
VOLUME_ANIMATION_DURATION_RANGE_MS);
WindowProxy.getInstance().setTimeout(
this.animateVolume_.bind(this), this.micVolumeDuration_);
}
/**
* @return {string}
* @private
*/
getText_() {
switch (this.state_) {
case State.STARTED:
return 'waiting';
case State.AUDIO_RECEIVED:
case State.SPEECH_RECEIVED:
return 'speak';
case State.RESULT_RECEIVED:
case State.RESULT_FINAL:
return 'result';
case State.ERROR_RECEIVED:
return 'error';
default:
return 'none';
}
}
/**
* @return {string}
* @private
*/
getErrorText_() {
switch (this.error_) {
case Error.kNoSpeech:
return 'no-speech';
case Error.kAudioCapture:
return 'audio-capture';
case Error.kNetwork:
return 'network';
case Error.kNotAllowed:
case Error.kServiceNotAllowed:
return 'not-allowed';
case Error.kLanguageNotSupported:
return 'language-not-supported';
case Error.kNoMatch:
return 'no-match';
case Error.kAborted:
case Error.kOther:
default:
return 'other';
}
}
/**
* @return {string}
* @private
*/
getErrorLink_() {
switch (this.error_) {
case Error.kNoSpeech:
case Error.kAudioCapture:
return 'learn-more';
case Error.kNotAllowed:
case Error.kServiceNotAllowed:
return 'details';
case Error.kNoMatch:
return 'try-again';
default:
return 'none';
}
}
/**
* @return {string}
* @private
*/
getMicClass_() {
switch (this.state_) {
case State.AUDIO_RECEIVED:
return 'listening';
case State.SPEECH_RECEIVED:
case State.RESULT_RECEIVED:
return 'receiving';
default:
return '';
}
}
}
customElements.define(VoiceSearchOverlayElement.is, VoiceSearchOverlayElement);