| // Copyright 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| (function() { |
| |
| // Correspond to steps in the hotword opt-in flow. |
| /** @const */ var START = 'start-container'; |
| /** @const */ var AUDIO_HISTORY = 'audio-history-container'; |
| /** @const */ var SPEECH_TRAINING = 'speech-training-container'; |
| /** @const */ var FINISH = 'finish-container'; |
| |
| /** |
| * These flows correspond to the three LaunchModes as defined in |
| * chrome/browser/search/hotword_service.h and should be kept in sync |
| * with them. |
| * @const |
| */ |
| var FLOWS = [ |
| [START, SPEECH_TRAINING, FINISH], |
| [START, AUDIO_HISTORY, SPEECH_TRAINING, FINISH], [SPEECH_TRAINING, FINISH] |
| ]; |
| |
| /** |
| * The launch mode. This enum needs to be kept in sync with that of |
| * the same name in hotword_service.h. |
| * @enum {number} |
| */ |
| var LaunchMode = {HOTWORD_ONLY: 0, HOTWORD_AND_AUDIO_HISTORY: 1, RETRAIN: 2}; |
| |
| /** |
| * The training state. |
| * @enum {string} |
| */ |
| var TrainingState = { |
| RESET: 'reset', |
| TIMEOUT: 'timeout', |
| ERROR: 'error', |
| }; |
| |
| /** |
| * Class to control the page flow of the always-on hotword and |
| * Audio History opt-in process. |
| * @constructor |
| */ |
| function Flow() { |
| this.currentStepIndex_ = -1; |
| this.currentFlow_ = []; |
| |
| /** |
| * The mode that this app was launched in. |
| * @private {LaunchMode} |
| */ |
| this.launchMode_ = LaunchMode.HOTWORD_AND_AUDIO_HISTORY; |
| |
| /** |
| * Whether this flow is currently in the process of training a voice model. |
| * @private {boolean} |
| */ |
| this.training_ = false; |
| |
| /** |
| * The current training state. |
| * @private {?TrainingState} |
| */ |
| this.trainingState_ = null; |
| |
| /** |
| * Whether an expected hotword trigger has been received, indexed by |
| * training step. |
| * @private {boolean[]} |
| */ |
| this.hotwordTriggerReceived_ = []; |
| |
| /** |
| * Prefix of the element ids for the page that is currently training. |
| * @private {string} |
| */ |
| this.trainingPagePrefix_ = 'speech-training'; |
| |
| /** |
| * Whether the speaker model for this flow has been finalized. |
| * @private {boolean} |
| */ |
| this.speakerModelFinalized_ = false; |
| |
| /** |
| * ID of the currently active timeout. |
| * @private {?number} |
| */ |
| this.timeoutId_ = null; |
| |
| /** |
| * Listener for the speakerModelSaved event. |
| * @private {Function} |
| */ |
| this.speakerModelFinalizedListener_ = |
| this.onSpeakerModelFinalized_.bind(this); |
| |
| /** |
| * Listener for the hotword trigger event. |
| * @private {Function} |
| */ |
| this.hotwordTriggerListener_ = this.handleHotwordTrigger_.bind(this); |
| |
| // Listen for the user locking the screen. |
| chrome.idle.onStateChanged.addListener( |
| this.handleIdleStateChanged_.bind(this)); |
| |
| // Listen for hotword settings changes. This used to detect when the user |
| // switches to a different profile. |
| if (chrome.hotwordPrivate.onEnabledChanged) { |
| chrome.hotwordPrivate.onEnabledChanged.addListener( |
| this.handleEnabledChanged_.bind(this)); |
| } |
| } |
| |
| /** |
| * Advances the current step. Begins training if the speech-training |
| * page has been reached. |
| */ |
| Flow.prototype.advanceStep = function() { |
| this.currentStepIndex_++; |
| if (this.currentStepIndex_ < this.currentFlow_.length) { |
| if (this.currentFlow_[this.currentStepIndex_] == SPEECH_TRAINING) |
| this.startTraining(); |
| this.showStep_.apply(this); |
| } |
| }; |
| |
| /** |
| * Gets the appropriate flow and displays its first page. |
| */ |
| Flow.prototype.startFlow = function() { |
| if (chrome.hotwordPrivate && chrome.hotwordPrivate.getLaunchState) |
| chrome.hotwordPrivate.getLaunchState(this.startFlowForMode_.bind(this)); |
| }; |
| |
| /** |
| * Starts the training process. |
| */ |
| Flow.prototype.startTraining = function() { |
| // Don't start a training session if one already exists. |
| if (this.training_) |
| return; |
| |
| this.training_ = true; |
| |
| if (chrome.hotwordPrivate.onHotwordTriggered && |
| !chrome.hotwordPrivate.onHotwordTriggered.hasListener( |
| this.hotwordTriggerListener_)) { |
| chrome.hotwordPrivate.onHotwordTriggered.addListener( |
| this.hotwordTriggerListener_); |
| } |
| |
| this.waitForHotwordTrigger_(0); |
| if (chrome.hotwordPrivate.startTraining) |
| chrome.hotwordPrivate.startTraining(); |
| }; |
| |
| /** |
| * Stops the training process. |
| */ |
| Flow.prototype.stopTraining = function() { |
| if (!this.training_) |
| return; |
| |
| this.training_ = false; |
| if (chrome.hotwordPrivate.onHotwordTriggered) { |
| chrome.hotwordPrivate.onHotwordTriggered.removeListener( |
| this.hotwordTriggerListener_); |
| } |
| if (chrome.hotwordPrivate.stopTraining) |
| chrome.hotwordPrivate.stopTraining(); |
| }; |
| |
| /** |
| * Attempts to enable audio history for the signed-in account. |
| */ |
| Flow.prototype.enableAudioHistory = function() { |
| // Update UI |
| $('audio-history-agree').disabled = true; |
| $('audio-history-cancel').disabled = true; |
| |
| $('audio-history-error').hidden = true; |
| $('audio-history-wait').hidden = false; |
| |
| if (chrome.hotwordPrivate.setAudioHistoryEnabled) { |
| chrome.hotwordPrivate.setAudioHistoryEnabled( |
| true, this.onAudioHistoryRequestCompleted_.bind(this)); |
| } |
| }; |
| |
| // ---- private methods: |
| |
| /** |
| * Shows an error if the audio history setting was not enabled successfully. |
| * @private |
| */ |
| Flow.prototype.handleAudioHistoryError_ = function() { |
| $('audio-history-agree').disabled = false; |
| $('audio-history-cancel').disabled = false; |
| |
| $('audio-history-wait').hidden = true; |
| $('audio-history-error').hidden = false; |
| |
| // Set a timeout before focusing the Enable button so that screenreaders |
| // have time to announce the error first. |
| this.setTimeout_(function() { |
| $('audio-history-agree').focus(); |
| }.bind(this), 50); |
| }; |
| |
| /** |
| * Callback for when an audio history request completes. |
| * @param {chrome.hotwordPrivate.AudioHistoryState} state The audio history |
| * request state. |
| * @private |
| */ |
| Flow.prototype.onAudioHistoryRequestCompleted_ = function(state) { |
| if (!state.success || !state.enabled) { |
| this.handleAudioHistoryError_(); |
| return; |
| } |
| |
| this.advanceStep(); |
| }; |
| |
| /** |
| * Shows an error if the speaker model has not been finalized. |
| * @private |
| */ |
| Flow.prototype.handleSpeakerModelFinalizedError_ = function() { |
| if (!this.training_) |
| return; |
| |
| if (this.speakerModelFinalized_) |
| return; |
| |
| this.updateTrainingState_(TrainingState.ERROR); |
| this.stopTraining(); |
| }; |
| |
| /** |
| * Handles the speaker model finalized event. |
| * @private |
| */ |
| Flow.prototype.onSpeakerModelFinalized_ = function() { |
| this.speakerModelFinalized_ = true; |
| if (chrome.hotwordPrivate.onSpeakerModelSaved) { |
| chrome.hotwordPrivate.onSpeakerModelSaved.removeListener( |
| this.speakerModelFinalizedListener_); |
| } |
| this.stopTraining(); |
| this.setTimeout_(this.finishFlow_.bind(this), 2000); |
| }; |
| |
| /** |
| * Completes the training process. |
| * @private |
| */ |
| Flow.prototype.finishFlow_ = function() { |
| if (chrome.hotwordPrivate.setHotwordAlwaysOnSearchEnabled) { |
| chrome.hotwordPrivate.setHotwordAlwaysOnSearchEnabled( |
| true, this.advanceStep.bind(this)); |
| } |
| }; |
| |
| /** |
| * Handles a user clicking on the retry button. |
| */ |
| Flow.prototype.handleRetry = function() { |
| if (!(this.trainingState_ == TrainingState.TIMEOUT || |
| this.trainingState_ == TrainingState.ERROR)) |
| return; |
| |
| this.startTraining(); |
| this.updateTrainingState_(TrainingState.RESET); |
| }; |
| |
| // ---- private methods: |
| |
| /** |
| * Completes the training process. |
| * @private |
| */ |
| Flow.prototype.finalizeSpeakerModel_ = function() { |
| if (!this.training_) |
| return; |
| |
| // Listen for the success event from the NaCl module. |
| if (chrome.hotwordPrivate.onSpeakerModelSaved && |
| !chrome.hotwordPrivate.onSpeakerModelSaved.hasListener( |
| this.speakerModelFinalizedListener_)) { |
| chrome.hotwordPrivate.onSpeakerModelSaved.addListener( |
| this.speakerModelFinalizedListener_); |
| } |
| |
| this.speakerModelFinalized_ = false; |
| this.setTimeout_(this.handleSpeakerModelFinalizedError_.bind(this), 30000); |
| if (chrome.hotwordPrivate.finalizeSpeakerModel) |
| chrome.hotwordPrivate.finalizeSpeakerModel(); |
| }; |
| |
| /** |
| * Returns the current training step. |
| * @param {string} curStepClassName The name of the class of the current |
| * training step. |
| * @return {Object} The current training step, its index, and an array of |
| * all training steps. Any of these can be undefined. |
| * @private |
| */ |
| Flow.prototype.getCurrentTrainingStep_ = function(curStepClassName) { |
| var steps = |
| $(this.trainingPagePrefix_ + '-training').querySelectorAll('.train'); |
| var curStep = |
| $(this.trainingPagePrefix_ + '-training').querySelector('.listening'); |
| |
| return { |
| current: curStep, |
| index: Array.prototype.indexOf.call(steps, curStep), |
| steps: steps |
| }; |
| }; |
| |
| /** |
| * Updates the training state. |
| * @param {TrainingState} state The training state. |
| * @private |
| */ |
| Flow.prototype.updateTrainingState_ = function(state) { |
| this.trainingState_ = state; |
| this.updateErrorUI_(); |
| }; |
| |
| /** |
| * Waits two minutes and then checks for a training error. |
| * @param {number} index The index of the training step. |
| * @private |
| */ |
| Flow.prototype.waitForHotwordTrigger_ = function(index) { |
| if (!this.training_) |
| return; |
| |
| this.hotwordTriggerReceived_[index] = false; |
| this.setTimeout_(this.handleTrainingTimeout_.bind(this, index), 120000); |
| }; |
| |
| /** |
| * Checks for and handles a training error. |
| * @param {number} index The index of the training step. |
| * @private |
| */ |
| Flow.prototype.handleTrainingTimeout_ = function(index) { |
| if (this.hotwordTriggerReceived_[index]) |
| return; |
| |
| this.timeoutTraining_(); |
| }; |
| |
| /** |
| * Times out training and updates the UI to show a "retry" message, if |
| * currently training. |
| * @private |
| */ |
| Flow.prototype.timeoutTraining_ = function() { |
| if (!this.training_) |
| return; |
| |
| this.clearTimeout_(); |
| this.updateTrainingState_(TrainingState.TIMEOUT); |
| this.stopTraining(); |
| }; |
| |
| /** |
| * Sets a timeout. If any timeout is active, clear it. |
| * @param {Function} func The function to invoke when the timeout occurs. |
| * @param {number} delay Timeout delay in milliseconds. |
| * @private |
| */ |
| Flow.prototype.setTimeout_ = function(func, delay) { |
| this.clearTimeout_(); |
| this.timeoutId_ = setTimeout(function() { |
| this.timeoutId_ = null; |
| func(); |
| }, delay); |
| }; |
| |
| /** |
| * Clears any currently active timeout. |
| * @private |
| */ |
| Flow.prototype.clearTimeout_ = function() { |
| if (this.timeoutId_ != null) { |
| clearTimeout(this.timeoutId_); |
| this.timeoutId_ = null; |
| } |
| }; |
| |
| /** |
| * Updates the training error UI. |
| * @private |
| */ |
| Flow.prototype.updateErrorUI_ = function() { |
| if (!this.training_) |
| return; |
| |
| var trainingSteps = this.getCurrentTrainingStep_('listening'); |
| var steps = trainingSteps.steps; |
| |
| $(this.trainingPagePrefix_ + '-toast').hidden = |
| this.trainingState_ != TrainingState.TIMEOUT; |
| if (this.trainingState_ == TrainingState.RESET) { |
| // We reset the training to begin at the first step. |
| // The first step is reset to 'listening', while the rest |
| // are reset to 'not-started'. |
| var prompt = loadTimeData.getString('trainingFirstPrompt'); |
| for (var i = 0; i < steps.length; ++i) { |
| steps[i].classList.remove('recorded'); |
| if (i == 0) { |
| steps[i].classList.remove('not-started'); |
| steps[i].classList.add('listening'); |
| } else { |
| steps[i].classList.add('not-started'); |
| if (i == steps.length - 1) |
| prompt = loadTimeData.getString('trainingLastPrompt'); |
| else |
| prompt = loadTimeData.getString('trainingMiddlePrompt'); |
| } |
| steps[i].querySelector('.text').textContent = prompt; |
| } |
| |
| // Reset the buttonbar. |
| $(this.trainingPagePrefix_ + '-processing').hidden = true; |
| $(this.trainingPagePrefix_ + '-wait').hidden = false; |
| $(this.trainingPagePrefix_ + '-error').hidden = true; |
| $(this.trainingPagePrefix_ + '-retry').hidden = true; |
| } else if (this.trainingState_ == TrainingState.TIMEOUT) { |
| var curStep = trainingSteps.current; |
| if (curStep) { |
| curStep.classList.remove('listening'); |
| curStep.classList.add('not-started'); |
| } |
| |
| // Set a timeout before focusing the Retry button so that screenreaders |
| // have time to announce the timeout first. |
| this.setTimeout_(function() { |
| $(this.trainingPagePrefix_ + '-toast').children[1].focus(); |
| }.bind(this), 50); |
| } else if (this.trainingState_ == TrainingState.ERROR) { |
| // Update the buttonbar. |
| $(this.trainingPagePrefix_ + '-wait').hidden = true; |
| $(this.trainingPagePrefix_ + '-error').hidden = false; |
| $(this.trainingPagePrefix_ + '-retry').hidden = false; |
| $(this.trainingPagePrefix_ + '-processing').hidden = false; |
| |
| // Set a timeout before focusing the Retry button so that screenreaders |
| // have time to announce the error first. |
| this.setTimeout_(function() { |
| $(this.trainingPagePrefix_ + '-retry').children[0].focus(); |
| }.bind(this), 50); |
| } |
| }; |
| |
| /** |
| * Handles a hotword trigger event and updates the training UI. |
| * @private |
| */ |
| Flow.prototype.handleHotwordTrigger_ = function() { |
| var trainingSteps = this.getCurrentTrainingStep_('listening'); |
| |
| if (!trainingSteps.current) |
| return; |
| |
| var index = trainingSteps.index; |
| this.hotwordTriggerReceived_[index] = true; |
| |
| trainingSteps.current.querySelector('.text').textContent = |
| loadTimeData.getString('trainingRecorded'); |
| trainingSteps.current.classList.remove('listening'); |
| trainingSteps.current.classList.add('recorded'); |
| |
| if (trainingSteps.steps[index + 1]) { |
| trainingSteps.steps[index + 1].classList.remove('not-started'); |
| trainingSteps.steps[index + 1].classList.add('listening'); |
| this.waitForHotwordTrigger_(index + 1); |
| return; |
| } |
| |
| // Only the last step makes it here. |
| var buttonElem = $(this.trainingPagePrefix_ + '-processing').hidden = false; |
| this.finalizeSpeakerModel_(); |
| }; |
| |
| /** |
| * Handles a chrome.idle.onStateChanged event and times out the training if |
| * the state is "locked". |
| * @param {!string} state State, one of "active", "idle", or "locked". |
| * @private |
| */ |
| Flow.prototype.handleIdleStateChanged_ = function(state) { |
| if (state == 'locked') |
| this.timeoutTraining_(); |
| }; |
| |
| /** |
| * Handles a chrome.hotwordPrivate.onEnabledChanged event and times out |
| * training if the user is no longer the active user (user switches profiles). |
| * @private |
| */ |
| Flow.prototype.handleEnabledChanged_ = function() { |
| if (chrome.hotwordPrivate.getStatus) { |
| chrome.hotwordPrivate.getStatus(function(status) { |
| if (status.userIsActive) |
| return; |
| |
| this.timeoutTraining_(); |
| }.bind(this)); |
| } |
| }; |
| |
| /** |
| * Gets and starts the appropriate flow for the launch mode. |
| * @param {chrome.hotwordPrivate.LaunchState} state Launch state of the |
| * Hotword Audio Verification App. |
| * @private |
| */ |
| Flow.prototype.startFlowForMode_ = function(state) { |
| this.launchMode_ = state.launchMode; |
| assert( |
| state.launchMode >= 0 && state.launchMode < FLOWS.length, |
| 'Invalid Launch Mode.'); |
| this.currentFlow_ = FLOWS[state.launchMode]; |
| if (state.launchMode == LaunchMode.HOTWORD_ONLY) { |
| $('intro-description-audio-history-enabled').hidden = false; |
| } else if (state.launchMode == LaunchMode.HOTWORD_AND_AUDIO_HISTORY) { |
| $('intro-description').hidden = false; |
| } |
| |
| this.advanceStep(); |
| }; |
| |
| /** |
| * Displays the current step. If the current step is not the first step, |
| * also hides the previous step. Focuses the current step's first button. |
| * @private |
| */ |
| Flow.prototype.showStep_ = function() { |
| var currentStepId = this.currentFlow_[this.currentStepIndex_]; |
| var currentStep = document.getElementById(currentStepId); |
| currentStep.hidden = false; |
| |
| cr.ui.setInitialFocus(currentStep); |
| |
| var previousStep = null; |
| if (this.currentStepIndex_ > 0) |
| previousStep = this.currentFlow_[this.currentStepIndex_ - 1]; |
| |
| if (previousStep) |
| document.getElementById(previousStep).hidden = true; |
| |
| chrome.app.window.current().show(); |
| }; |
| |
| window.Flow = Flow; |
| })(); |