blob: c22211492c4e80efcac9922be8393c0a94d0a446 [file] [log] [blame]
// 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;
})();