blob: 98e0a5cbd1740588c3e2539170ec5fc78c2750b6 [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.
cr.define('hotword', function() {
'use strict';
/**
* Trivial container class for session information.
* @param {!hotword.constants.SessionSource} source Source of the hotword
* session.
* @param {!function()} triggerCb Callback invoked when the hotword has
* triggered.
* @param {!function()} startedCb Callback invoked when the session has
* been started successfully.
* @param {function()=} opt_modelSavedCb Callback invoked when the speaker
* model has been saved successfully.
* @constructor
* @struct
* @private
*/
function Session_(source, triggerCb, startedCb, opt_modelSavedCb) {
/**
* Source of the hotword session request.
* @private {!hotword.constants.SessionSource}
*/
this.source_ = source;
/**
* Callback invoked when the hotword has triggered.
* @private {!function()}
*/
this.triggerCb_ = triggerCb;
/**
* Callback invoked when the session has been started successfully.
* @private {?function()}
*/
this.startedCb_ = startedCb;
/**
* Callback invoked when the session has been started successfully.
* @private {?function()}
*/
this.speakerModelSavedCb_ = opt_modelSavedCb;
}
/**
* Class to manage hotwording state. Starts/stops the hotword detector based
* on user settings, session requests, and any other factors that play into
* whether or not hotwording should be running.
* @constructor
*/
function StateManager() {
/**
* Current state.
* @private {hotword.StateManager.State_}
*/
this.state_ = State_.STOPPED;
/**
* Current hotwording status.
* @private {?chrome.hotwordPrivate.StatusDetails}
*/
this.hotwordStatus_ = null;
/**
* NaCl plugin manager.
* @private {?hotword.NaClManager}
*/
this.pluginManager_ = null;
/**
* Currently active hotwording sessions.
* @private {!Array<Session_>}
*/
this.sessions_ = [];
/**
* The mode to start the recognizer in.
* @private {!hotword.constants.RecognizerStartMode}
*/
this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
/**
* Event that fires when the hotwording status has changed.
* @type {!ChromeEvent}
*/
this.onStatusChanged = new chrome.Event();
/**
* Hotword trigger audio notification... a.k.a The Chime (tm).
* @private {!HTMLAudioElement}
*/
this.chime_ =
/** @type {!HTMLAudioElement} */ (document.createElement('audio'));
/**
* Chrome event listeners. Saved so that they can be de-registered when
* hotwording is disabled.
* @private
*/
this.idleStateChangedListener_ = this.handleIdleStateChanged_.bind(this);
this.startupListener_ = this.handleStartup_.bind(this);
/**
* Whether this user is locked.
* @private {boolean}
*/
this.isLocked_ = false;
/**
* Current state of audio logging.
* This is tracked separately from hotwordStatus_ because we need to restart
* the hotword detector when this value changes.
* @private {boolean}
*/
this.loggingEnabled_ = false;
/**
* Current state of training.
* This is tracked separately from |hotwordStatus_| because we need to
* restart the hotword detector when this value changes.
* @private {!boolean}
*/
this.trainingEnabled_ = false;
/**
* Helper class to keep this extension alive while the hotword detector is
* running in always-on mode.
* @private {!hotword.KeepAlive}
*/
this.keepAlive_ = new hotword.KeepAlive();
// Get the initial status.
chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this));
// Setup the chime and insert into the page.
// Set preload=none to prevent an audio output stream from being created
// when the extension loads.
this.chime_.preload = 'none';
this.chime_.src = chrome.extension.getURL(
hotword.constants.SHARED_MODULE_ROOT + '/audio/chime.wav');
document.body.appendChild(this.chime_);
// In order to remove this listener, it must first be added. This handles
// the case on first Chrome startup where this event is never registered,
// so can't be removed when it's determined that hotwording is disabled.
// Why not only remove the listener if it exists? Extension API events have
// two parts to them, the Javascript listeners, and a browser-side component
// that wakes up the extension if it's an event page. The browser-side
// wake-up event is only removed when the number of javascript listeners
// becomes 0. To clear the browser wake-up event, a listener first needs to
// be added, then removed in order to drop the count to 0 and remove the
// event.
chrome.runtime.onStartup.addListener(this.startupListener_);
}
/**
* @enum {number}
* @private
*/
StateManager.State_ = {
STOPPED: 0,
STARTING: 1,
RUNNING: 2,
ERROR: 3,
};
var State_ = StateManager.State_;
var UmaMediaStreamOpenResults_ = {
// These first error are defined by the MediaStream spec:
// http://w3c.github.io/mediacapture-main/getusermedia.html#idl-def-MediaStreamError
'NotSupportedError':
hotword.constants.UmaMediaStreamOpenResult.NOT_SUPPORTED,
'PermissionDeniedError':
hotword.constants.UmaMediaStreamOpenResult.PERMISSION_DENIED,
'ConstraintNotSatisfiedError':
hotword.constants.UmaMediaStreamOpenResult.CONSTRAINT_NOT_SATISFIED,
'OverconstrainedError':
hotword.constants.UmaMediaStreamOpenResult.OVERCONSTRAINED,
'NotFoundError': hotword.constants.UmaMediaStreamOpenResult.NOT_FOUND,
'AbortError': hotword.constants.UmaMediaStreamOpenResult.ABORT,
'SourceUnavailableError':
hotword.constants.UmaMediaStreamOpenResult.SOURCE_UNAVAILABLE,
// The next few errors are chrome-specific. See:
// content/renderer/media/user_media_client_impl.cc
// (UserMediaClientImpl::GetUserMediaRequestFailed)
'PermissionDismissedError':
hotword.constants.UmaMediaStreamOpenResult.PERMISSION_DISMISSED,
'InvalidStateError':
hotword.constants.UmaMediaStreamOpenResult.INVALID_STATE,
'DevicesNotFoundError':
hotword.constants.UmaMediaStreamOpenResult.DEVICES_NOT_FOUND,
'InvalidSecurityOriginError':
hotword.constants.UmaMediaStreamOpenResult.INVALID_SECURITY_ORIGIN
};
var UmaTriggerSources_ = {
'launcher': hotword.constants.UmaTriggerSource.LAUNCHER,
'ntp': hotword.constants.UmaTriggerSource.NTP_GOOGLE_COM,
'always': hotword.constants.UmaTriggerSource.ALWAYS_ON,
'training': hotword.constants.UmaTriggerSource.TRAINING
};
StateManager.prototype = {
/**
* Request status details update. Intended to be called from the
* hotwordPrivate.onEnabledChanged() event.
*/
updateStatus: function() {
chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this));
},
/**
* @return {boolean} True if google.com/NTP/launcher hotwording is enabled.
*/
isSometimesOnEnabled: function() {
assert(
this.hotwordStatus_, 'No hotwording status (isSometimesOnEnabled)');
// Although the two settings are supposed to be mutually exclusive, it's
// possible for both to be set. In that case, always-on takes precedence.
return this.hotwordStatus_.enabled &&
!this.hotwordStatus_.alwaysOnEnabled;
},
/**
* @return {boolean} True if always-on hotwording is enabled.
*/
isAlwaysOnEnabled: function() {
assert(this.hotwordStatus_, 'No hotword status (isAlwaysOnEnabled)');
return this.hotwordStatus_.alwaysOnEnabled &&
!this.hotwordStatus_.trainingEnabled;
},
/**
* @return {boolean} True if training is enabled.
*/
isTrainingEnabled: function() {
assert(this.hotwordStatus_, 'No hotword status (isTrainingEnabled)');
return this.hotwordStatus_.trainingEnabled;
},
/**
* Callback for hotwordPrivate.getStatus() function.
* @param {chrome.hotwordPrivate.StatusDetails} status Current hotword
* status.
* @private
*/
handleStatus_: function(status) {
hotword.debug('New hotword status', status);
this.hotwordStatus_ = status;
this.updateStateFromStatus_();
this.onStatusChanged.dispatch();
},
/**
* Updates state based on the current status.
* @private
*/
updateStateFromStatus_: function() {
if (!this.hotwordStatus_)
return;
if (this.hotwordStatus_.enabled || this.hotwordStatus_.alwaysOnEnabled ||
this.hotwordStatus_.trainingEnabled) {
// Detect changes to audio logging and kill the detector if that setting
// has changed.
if (this.hotwordStatus_.audioLoggingEnabled != this.loggingEnabled_)
this.shutdownDetector_();
this.loggingEnabled_ = this.hotwordStatus_.audioLoggingEnabled;
// If the training state has changed, we need to first shut down the
// detector so that we can restart in a different mode.
if (this.hotwordStatus_.trainingEnabled != this.trainingEnabled_)
this.shutdownDetector_();
this.trainingEnabled_ = this.hotwordStatus_.trainingEnabled;
// Start the detector if there's a session and the user is unlocked, and
// stops it otherwise.
if (this.sessions_.length && !this.isLocked_ &&
this.hotwordStatus_.userIsActive) {
this.startDetector_();
} else {
this.shutdownDetector_();
}
if (!chrome.idle.onStateChanged.hasListener(
this.idleStateChangedListener_)) {
chrome.idle.onStateChanged.addListener(
this.idleStateChangedListener_);
}
if (!chrome.runtime.onStartup.hasListener(this.startupListener_))
chrome.runtime.onStartup.addListener(this.startupListener_);
} else {
// Not enabled. Shut down if running.
this.shutdownDetector_();
chrome.idle.onStateChanged.removeListener(
this.idleStateChangedListener_);
// If hotwording isn't enabled, don't start this component extension on
// Chrome startup. If a user enables hotwording, the status change
// event will be fired and the onStartup event will be registered.
chrome.runtime.onStartup.removeListener(this.startupListener_);
}
},
/**
* Starts the hotword detector.
* @private
*/
startDetector_: function() {
// Last attempt to start detector resulted in an error.
if (this.state_ == State_.ERROR) {
// TODO(amistry): Do some error rate tracking here and disable the
// extension if we error too often.
}
if (!this.pluginManager_) {
this.state_ = State_.STARTING;
var isHotwordStream = this.isAlwaysOnEnabled() &&
this.hotwordStatus_.hotwordHardwareAvailable;
this.pluginManager_ =
new hotword.NaClManager(this.loggingEnabled_, isHotwordStream);
this.pluginManager_.addEventListener(
hotword.constants.Event.READY, this.onReady_.bind(this));
this.pluginManager_.addEventListener(
hotword.constants.Event.ERROR, this.onError_.bind(this));
this.pluginManager_.addEventListener(
hotword.constants.Event.TRIGGER, this.onTrigger_.bind(this));
this.pluginManager_.addEventListener(
hotword.constants.Event.TIMEOUT, this.onTimeout_.bind(this));
this.pluginManager_.addEventListener(
hotword.constants.Event.SPEAKER_MODEL_SAVED,
this.onSpeakerModelSaved_.bind(this));
chrome.runtime.getPlatformInfo(function(platform) {
var naclArch = platform.nacl_arch;
// googDucking set to false so that audio output level from other tabs
// is not affected when hotword is enabled. https://crbug.com/357773
// content/common/media/media_stream_options.cc
// When always-on is enabled, request the hotword stream.
// Optional because we allow power users to bypass the hardware
// detection via a flag, and hence the hotword stream may not be
// available.
var constraints = /** @type {googMediaStreamConstraints} */
({
audio: {
optional: [
{googDucking: false},
{googHotword: this.isAlwaysOnEnabled()}
]
}
});
navigator.webkitGetUserMedia(
/** @type {MediaStreamConstraints} */ (constraints),
function(stream) {
hotword.metrics.recordEnum(
hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
hotword.constants.UmaMediaStreamOpenResult.SUCCESS,
hotword.constants.UmaMediaStreamOpenResult.MAX);
// The detector could have been shut down before the stream
// finishes opening.
if (this.pluginManager_ == null) {
stream.getAudioTracks()[0].stop();
return;
}
if (this.isAlwaysOnEnabled())
this.keepAlive_.start();
if (!this.pluginManager_.initialize(naclArch, stream)) {
this.state_ = State_.ERROR;
this.shutdownPluginManager_();
}
}.bind(this),
function(error) {
if (error.name in UmaMediaStreamOpenResults_) {
var metricValue = UmaMediaStreamOpenResults_[error.name];
} else {
var metricValue =
hotword.constants.UmaMediaStreamOpenResult.UNKNOWN;
}
hotword.metrics.recordEnum(
hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
metricValue,
hotword.constants.UmaMediaStreamOpenResult.MAX);
this.state_ = State_.ERROR;
this.pluginManager_ = null;
}.bind(this));
}.bind(this));
} else if (this.state_ != State_.STARTING) {
// Don't try to start a starting detector.
this.startRecognizer_();
}
},
/**
* Start the recognizer plugin. Assumes the plugin has been loaded and is
* ready to start.
* @private
*/
startRecognizer_: function() {
assert(this.pluginManager_, 'No NaCl plugin loaded');
if (this.state_ != State_.RUNNING) {
this.state_ = State_.RUNNING;
if (this.isAlwaysOnEnabled())
this.keepAlive_.start();
this.pluginManager_.startRecognizer(this.startMode_);
}
for (var i = 0; i < this.sessions_.length; i++) {
var session = this.sessions_[i];
if (session.startedCb_) {
session.startedCb_();
session.startedCb_ = null;
}
}
},
/**
* Stops the hotword detector, if it's running.
* @private
*/
stopDetector_: function() {
this.keepAlive_.stop();
if (this.pluginManager_ && this.state_ == State_.RUNNING) {
this.state_ = State_.STOPPED;
this.pluginManager_.stopRecognizer();
}
},
/**
* Shuts down and removes the plugin manager, if it exists.
* @private
*/
shutdownPluginManager_: function() {
this.keepAlive_.stop();
if (this.pluginManager_) {
this.pluginManager_.shutdown();
this.pluginManager_ = null;
}
},
/**
* Shuts down the hotword detector.
* @private
*/
shutdownDetector_: function() {
this.state_ = State_.STOPPED;
this.shutdownPluginManager_();
},
/**
* Finalizes the speaker model. Assumes the plugin has been loaded and
* started.
*/
finalizeSpeakerModel: function() {
assert(
this.pluginManager_,
'Cannot finalize speaker model: No NaCl plugin loaded');
if (this.state_ != State_.RUNNING) {
hotword.debug('Cannot finalize speaker model: NaCl plugin not started');
return;
}
this.pluginManager_.finalizeSpeakerModel();
},
/**
* Handle the hotword plugin being ready to start.
* @private
*/
onReady_: function() {
if (this.state_ != State_.STARTING) {
// At this point, we should not be in the RUNNING state. Doing so would
// imply the hotword detector was started without being ready.
assert(this.state_ != State_.RUNNING, 'Unexpected RUNNING state');
this.shutdownPluginManager_();
return;
}
this.startRecognizer_();
},
/**
* Handle an error from the hotword plugin.
* @private
*/
onError_: function() {
this.state_ = State_.ERROR;
this.shutdownPluginManager_();
},
/**
* Handle hotword triggering.
* @param {!Event} event Event containing audio log data.
* @private
*/
onTrigger_: function(event) {
this.keepAlive_.stop();
hotword.debug('Hotword triggered!');
chrome.metricsPrivate.recordUserAction(
hotword.constants.UmaMetrics.TRIGGER);
assert(this.pluginManager_, 'No NaCl plugin loaded on trigger');
// Detector implicitly stops when the hotword is detected.
this.state_ = State_.STOPPED;
// Play the chime.
this.chime_.play();
// Implicitly clear the top session. A session needs to be started in
// order to restart the detector.
if (this.sessions_.length) {
var session = this.sessions_.pop();
session.triggerCb_(event.log);
hotword.metrics.recordEnum(
hotword.constants.UmaMetrics.TRIGGER_SOURCE,
UmaTriggerSources_[session.source_],
hotword.constants.UmaTriggerSource.MAX);
}
// If we're in always-on mode, shut down the hotword detector. The hotword
// stream requires that we close and re-open it after a trigger, and the
// only way to accomplish this is to shut everything down.
if (this.isAlwaysOnEnabled())
this.shutdownDetector_();
},
/**
* Handle hotword timeout.
* @private
*/
onTimeout_: function() {
hotword.debug('Hotword timeout!');
// We get this event when the hotword detector thinks there's a false
// trigger. In this case, we need to shut down and restart the detector to
// re-arm the DSP.
this.shutdownDetector_();
this.updateStateFromStatus_();
},
/**
* Handle speaker model saved.
* @private
*/
onSpeakerModelSaved_: function() {
hotword.debug('Speaker model saved!');
if (this.sessions_.length) {
// Only call the callback of the the top session.
var session = this.sessions_[this.sessions_.length - 1];
if (session.speakerModelSavedCb_)
session.speakerModelSavedCb_();
}
},
/**
* Remove a hotwording session from the given source.
* @param {!hotword.constants.SessionSource} source Source of the hotword
* session request.
* @private
*/
removeSession_: function(source) {
for (var i = 0; i < this.sessions_.length; i++) {
if (this.sessions_[i].source_ == source) {
this.sessions_.splice(i, 1);
break;
}
}
},
/**
* Start a hotwording session.
* @param {!hotword.constants.SessionSource} source Source of the hotword
* session request.
* @param {!function()} startedCb Callback invoked when the session has
* been started successfully.
* @param {!function()} triggerCb Callback invoked when the hotword has
* @param {function()=} modelSavedCb Callback invoked when the speaker model
* has been saved.
* @param {hotword.constants.RecognizerStartMode=} opt_mode The mode to
* start the recognizer in.
*/
startSession: function(
source, startedCb, triggerCb, opt_modelSavedCb, opt_mode) {
if (this.isTrainingEnabled() && opt_mode) {
this.startMode_ = opt_mode;
} else {
this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
}
hotword.debug('Starting session for source: ' + source);
this.removeSession_(source);
this.sessions_.push(
new Session_(source, triggerCb, startedCb, opt_modelSavedCb));
this.updateStateFromStatus_();
},
/**
* Stops a hotwording session.
* @param {!hotword.constants.SessionSource} source Source of the hotword
* session request.
*/
stopSession: function(source) {
hotword.debug('Stopping session for source: ' + source);
this.removeSession_(source);
// If this is a training session then switch the start mode back to
// normal.
if (source == hotword.constants.SessionSource.TRAINING)
this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
this.updateStateFromStatus_();
},
/**
* Handles a chrome.idle.onStateChanged event.
* @param {!string} state State, one of "active", "idle", or "locked".
* @private
*/
handleIdleStateChanged_: function(state) {
hotword.debug('Idle state changed: ' + state);
var oldLocked = this.isLocked_;
if (state == 'locked')
this.isLocked_ = true;
else
this.isLocked_ = false;
if (oldLocked != this.isLocked_)
this.updateStateFromStatus_();
},
/**
* Handles a chrome.runtime.onStartup event.
* @private
*/
handleStartup_: function() {
// Nothing specific needs to be done here. This function exists solely to
// be registered on the startup event.
}
};
return {StateManager: StateManager};
});