1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 cr.define('hotword', function() {
9 * Trivial container class for session information.
10 * @param {!hotword.constants.SessionSource} source Source of the hotword
12 * @param {!function()} triggerCb Callback invoked when the hotword has
14 * @param {!function()} startedCb Callback invoked when the session has
15 * been started successfully.
16 * @param {function()=} opt_modelSavedCb Callback invoked when the speaker
17 * model has been saved successfully.
22 function Session_(source, triggerCb, startedCb, opt_modelSavedCb) {
24 * Source of the hotword session request.
25 * @private {!hotword.constants.SessionSource}
27 this.source_ = source;
30 * Callback invoked when the hotword has triggered.
31 * @private {!function()}
33 this.triggerCb_ = triggerCb;
36 * Callback invoked when the session has been started successfully.
37 * @private {?function()}
39 this.startedCb_ = startedCb;
42 * Callback invoked when the session has been started successfully.
43 * @private {?function()}
45 this.speakerModelSavedCb_ = opt_modelSavedCb;
49 * Class to manage hotwording state. Starts/stops the hotword detector based
50 * on user settings, session requests, and any other factors that play into
51 * whether or not hotwording should be running.
54 function StateManager() {
57 * @private {hotword.StateManager.State_}
59 this.state_ = State_.STOPPED;
62 * Current hotwording status.
63 * @private {?chrome.hotwordPrivate.StatusDetails}
65 this.hotwordStatus_ = null;
68 * NaCl plugin manager.
69 * @private {?hotword.NaClManager}
71 this.pluginManager_ = null;
74 * Currently active hotwording sessions.
75 * @private {!Array<Session_>}
80 * The mode to start the recognizer in.
81 * @private {!hotword.constants.RecognizerStartMode}
83 this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
86 * Event that fires when the hotwording status has changed.
87 * @type {!ChromeEvent}
89 this.onStatusChanged = new chrome.Event();
92 * Hotword trigger audio notification... a.k.a The Chime (tm).
93 * @private {!HTMLAudioElement}
96 /** @type {!HTMLAudioElement} */(document.createElement('audio'));
99 * Chrome event listeners. Saved so that they can be de-registered when
100 * hotwording is disabled.
103 this.idleStateChangedListener_ = this.handleIdleStateChanged_.bind(this);
106 * Whether this user is locked.
109 this.isLocked_ = false;
112 * Current state of audio logging.
113 * This is tracked separately from hotwordStatus_ because we need to restart
114 * the hotword detector when this value changes.
117 this.loggingEnabled_ = false;
120 * Current state of training.
121 * This is tracked separately from |hotwordStatus_| because we need to
122 * restart the hotword detector when this value changes.
123 * @private {!boolean}
125 this.trainingEnabled_ = false;
128 * Helper class to keep this extension alive while the hotword detector is
129 * running in always-on mode.
130 * @private {!hotword.KeepAlive}
132 this.keepAlive_ = new hotword.KeepAlive();
134 // Get the initial status.
135 chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this));
137 // Setup the chime and insert into the page.
138 // Set preload=none to prevent an audio output stream from being created
139 // when the extension loads.
140 this.chime_.preload = 'none';
141 this.chime_.src = chrome.extension.getURL(
142 hotword.constants.SHARED_MODULE_ROOT + '/audio/chime.wav');
143 document.body.appendChild(this.chime_);
150 StateManager.State_ = {
156 var State_ = StateManager.State_;
158 var UmaMediaStreamOpenResults_ = {
159 // These first error are defined by the MediaStream spec:
160 // http://w3c.github.io/mediacapture-main/getusermedia.html#idl-def-MediaStreamError
162 hotword.constants.UmaMediaStreamOpenResult.NOT_SUPPORTED,
163 'PermissionDeniedError':
164 hotword.constants.UmaMediaStreamOpenResult.PERMISSION_DENIED,
165 'ConstraintNotSatisfiedError':
166 hotword.constants.UmaMediaStreamOpenResult.CONSTRAINT_NOT_SATISFIED,
167 'OverconstrainedError':
168 hotword.constants.UmaMediaStreamOpenResult.OVERCONSTRAINED,
169 'NotFoundError': hotword.constants.UmaMediaStreamOpenResult.NOT_FOUND,
170 'AbortError': hotword.constants.UmaMediaStreamOpenResult.ABORT,
171 'SourceUnavailableError':
172 hotword.constants.UmaMediaStreamOpenResult.SOURCE_UNAVAILABLE,
173 // The next few errors are chrome-specific. See:
174 // content/renderer/media/user_media_client_impl.cc
175 // (UserMediaClientImpl::GetUserMediaRequestFailed)
176 'PermissionDismissedError':
177 hotword.constants.UmaMediaStreamOpenResult.PERMISSION_DISMISSED,
179 hotword.constants.UmaMediaStreamOpenResult.INVALID_STATE,
180 'DevicesNotFoundError':
181 hotword.constants.UmaMediaStreamOpenResult.DEVICES_NOT_FOUND,
182 'InvalidSecurityOriginError':
183 hotword.constants.UmaMediaStreamOpenResult.INVALID_SECURITY_ORIGIN
186 var UmaTriggerSources_ = {
187 'launcher': hotword.constants.UmaTriggerSource.LAUNCHER,
188 'ntp': hotword.constants.UmaTriggerSource.NTP_GOOGLE_COM,
189 'always': hotword.constants.UmaTriggerSource.ALWAYS_ON,
190 'training': hotword.constants.UmaTriggerSource.TRAINING
193 StateManager.prototype = {
195 * Request status details update. Intended to be called from the
196 * hotwordPrivate.onEnabledChanged() event.
198 updateStatus: function() {
199 chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this));
203 * @return {boolean} True if google.com/NTP/launcher hotwording is enabled.
205 isSometimesOnEnabled: function() {
206 assert(this.hotwordStatus_,
207 'No hotwording status (isSometimesOnEnabled)');
208 // Although the two settings are supposed to be mutually exclusive, it's
209 // possible for both to be set. In that case, always-on takes precedence.
210 return this.hotwordStatus_.enabled &&
211 !this.hotwordStatus_.alwaysOnEnabled;
215 * @return {boolean} True if always-on hotwording is enabled.
217 isAlwaysOnEnabled: function() {
218 assert(this.hotwordStatus_, 'No hotword status (isAlwaysOnEnabled)');
219 return this.hotwordStatus_.alwaysOnEnabled &&
220 !this.hotwordStatus_.trainingEnabled;
224 * @return {boolean} True if training is enabled.
226 isTrainingEnabled: function() {
227 assert(this.hotwordStatus_, 'No hotword status (isTrainingEnabled)');
228 return this.hotwordStatus_.trainingEnabled;
232 * Callback for hotwordPrivate.getStatus() function.
233 * @param {chrome.hotwordPrivate.StatusDetails} status Current hotword
237 handleStatus_: function(status) {
238 hotword.debug('New hotword status', status);
239 this.hotwordStatus_ = status;
240 this.updateStateFromStatus_();
242 this.onStatusChanged.dispatch();
246 * Updates state based on the current status.
249 updateStateFromStatus_: function() {
250 if (!this.hotwordStatus_)
253 if (this.hotwordStatus_.enabled ||
254 this.hotwordStatus_.alwaysOnEnabled ||
255 this.hotwordStatus_.trainingEnabled) {
256 // Detect changes to audio logging and kill the detector if that setting
258 if (this.hotwordStatus_.audioLoggingEnabled != this.loggingEnabled_)
259 this.shutdownDetector_();
260 this.loggingEnabled_ = this.hotwordStatus_.audioLoggingEnabled;
262 // If the training state has changed, we need to first shut down the
263 // detector so that we can restart in a different mode.
264 if (this.hotwordStatus_.trainingEnabled != this.trainingEnabled_)
265 this.shutdownDetector_();
266 this.trainingEnabled_ = this.hotwordStatus_.trainingEnabled;
268 // Start the detector if there's a session and the user is unlocked, and
269 // stops it otherwise.
270 if (this.sessions_.length && !this.isLocked_ &&
271 this.hotwordStatus_.userIsActive) {
272 this.startDetector_();
274 this.shutdownDetector_();
277 if (!chrome.idle.onStateChanged.hasListener(
278 this.idleStateChangedListener_)) {
279 chrome.idle.onStateChanged.addListener(
280 this.idleStateChangedListener_);
283 // Not enabled. Shut down if running.
284 this.shutdownDetector_();
286 chrome.idle.onStateChanged.removeListener(
287 this.idleStateChangedListener_);
292 * Starts the hotword detector.
295 startDetector_: function() {
296 // Last attempt to start detector resulted in an error.
297 if (this.state_ == State_.ERROR) {
298 // TODO(amistry): Do some error rate tracking here and disable the
299 // extension if we error too often.
302 if (!this.pluginManager_) {
303 this.state_ = State_.STARTING;
304 this.pluginManager_ = new hotword.NaClManager(this.loggingEnabled_);
305 this.pluginManager_.addEventListener(hotword.constants.Event.READY,
306 this.onReady_.bind(this));
307 this.pluginManager_.addEventListener(hotword.constants.Event.ERROR,
308 this.onError_.bind(this));
309 this.pluginManager_.addEventListener(hotword.constants.Event.TRIGGER,
310 this.onTrigger_.bind(this));
311 this.pluginManager_.addEventListener(hotword.constants.Event.TIMEOUT,
312 this.onTimeout_.bind(this));
313 this.pluginManager_.addEventListener(
314 hotword.constants.Event.SPEAKER_MODEL_SAVED,
315 this.onSpeakerModelSaved_.bind(this));
316 chrome.runtime.getPlatformInfo(function(platform) {
317 var naclArch = platform.nacl_arch;
319 // googDucking set to false so that audio output level from other tabs
320 // is not affected when hotword is enabled. https://crbug.com/357773
321 // content/common/media/media_stream_options.cc
322 // When always-on is enabled, request the hotword stream.
323 // Optional because we allow power users to bypass the hardware
324 // detection via a flag, and hence the hotword stream may not be
326 var constraints = /** @type {googMediaStreamConstraints} */
327 ({audio: {optional: [
328 { googDucking: false },
329 { googHotword: this.isAlwaysOnEnabled() }
331 navigator.webkitGetUserMedia(
332 /** @type {MediaStreamConstraints} */ (constraints),
334 hotword.metrics.recordEnum(
335 hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
336 hotword.constants.UmaMediaStreamOpenResult.SUCCESS,
337 hotword.constants.UmaMediaStreamOpenResult.MAX);
338 // The detector could have been shut down before the stream
340 if (this.pluginManager_ == null) {
341 stream.getAudioTracks()[0].stop();
345 if (this.isAlwaysOnEnabled())
346 this.keepAlive_.start();
347 if (!this.pluginManager_.initialize(naclArch, stream)) {
348 this.state_ = State_.ERROR;
349 this.shutdownPluginManager_();
353 if (error.name in UmaMediaStreamOpenResults_) {
354 var metricValue = UmaMediaStreamOpenResults_[error.name];
357 hotword.constants.UmaMediaStreamOpenResult.UNKNOWN;
359 hotword.metrics.recordEnum(
360 hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
362 hotword.constants.UmaMediaStreamOpenResult.MAX);
363 this.state_ = State_.ERROR;
364 this.pluginManager_ = null;
367 } else if (this.state_ != State_.STARTING) {
368 // Don't try to start a starting detector.
369 this.startRecognizer_();
374 * Start the recognizer plugin. Assumes the plugin has been loaded and is
378 startRecognizer_: function() {
379 assert(this.pluginManager_, 'No NaCl plugin loaded');
380 if (this.state_ != State_.RUNNING) {
381 this.state_ = State_.RUNNING;
382 if (this.isAlwaysOnEnabled())
383 this.keepAlive_.start();
384 this.pluginManager_.startRecognizer(this.startMode_);
386 for (var i = 0; i < this.sessions_.length; i++) {
387 var session = this.sessions_[i];
388 if (session.startedCb_) {
389 session.startedCb_();
390 session.startedCb_ = null;
396 * Stops the hotword detector, if it's running.
399 stopDetector_: function() {
400 this.keepAlive_.stop();
401 if (this.pluginManager_ && this.state_ == State_.RUNNING) {
402 this.state_ = State_.STOPPED;
403 this.pluginManager_.stopRecognizer();
408 * Shuts down and removes the plugin manager, if it exists.
411 shutdownPluginManager_: function() {
412 this.keepAlive_.stop();
413 if (this.pluginManager_) {
414 this.pluginManager_.shutdown();
415 this.pluginManager_ = null;
420 * Shuts down the hotword detector.
423 shutdownDetector_: function() {
424 this.state_ = State_.STOPPED;
425 this.shutdownPluginManager_();
429 * Finalizes the speaker model. Assumes the plugin has been loaded and
432 finalizeSpeakerModel: function() {
433 assert(this.pluginManager_,
434 'Cannot finalize speaker model: No NaCl plugin loaded');
435 if (this.state_ != State_.RUNNING) {
436 hotword.debug('Cannot finalize speaker model: NaCl plugin not started');
439 this.pluginManager_.finalizeSpeakerModel();
443 * Handle the hotword plugin being ready to start.
446 onReady_: function() {
447 if (this.state_ != State_.STARTING) {
448 // At this point, we should not be in the RUNNING state. Doing so would
449 // imply the hotword detector was started without being ready.
450 assert(this.state_ != State_.RUNNING, 'Unexpected RUNNING state');
451 this.shutdownPluginManager_();
454 this.startRecognizer_();
458 * Handle an error from the hotword plugin.
461 onError_: function() {
462 this.state_ = State_.ERROR;
463 this.shutdownPluginManager_();
467 * Handle hotword triggering.
468 * @param {!Event} event Event containing audio log data.
471 onTrigger_: function(event) {
472 this.keepAlive_.stop();
473 hotword.debug('Hotword triggered!');
474 chrome.metricsPrivate.recordUserAction(
475 hotword.constants.UmaMetrics.TRIGGER);
476 assert(this.pluginManager_, 'No NaCl plugin loaded on trigger');
477 // Detector implicitly stops when the hotword is detected.
478 this.state_ = State_.STOPPED;
483 // Implicitly clear the top session. A session needs to be started in
484 // order to restart the detector.
485 if (this.sessions_.length) {
486 var session = this.sessions_.pop();
487 session.triggerCb_(event.log);
489 hotword.metrics.recordEnum(
490 hotword.constants.UmaMetrics.TRIGGER_SOURCE,
491 UmaTriggerSources_[session.source_],
492 hotword.constants.UmaTriggerSource.MAX);
495 // If we're in always-on mode, shut down the hotword detector. The hotword
496 // stream requires that we close and re-open it after a trigger, and the
497 // only way to accomplish this is to shut everything down.
498 if (this.isAlwaysOnEnabled())
499 this.shutdownDetector_();
503 * Handle hotword timeout.
506 onTimeout_: function() {
507 hotword.debug('Hotword timeout!');
509 // We get this event when the hotword detector thinks there's a false
510 // trigger. In this case, we need to shut down and restart the detector to
512 this.shutdownDetector_();
513 this.updateStateFromStatus_();
517 * Handle speaker model saved.
520 onSpeakerModelSaved_: function() {
521 hotword.debug('Speaker model saved!');
523 if (this.sessions_.length) {
524 // Only call the callback of the the top session.
525 var session = this.sessions_[this.sessions_.length - 1];
526 if (session.speakerModelSavedCb_)
527 session.speakerModelSavedCb_();
532 * Remove a hotwording session from the given source.
533 * @param {!hotword.constants.SessionSource} source Source of the hotword
537 removeSession_: function(source) {
538 for (var i = 0; i < this.sessions_.length; i++) {
539 if (this.sessions_[i].source_ == source) {
540 this.sessions_.splice(i, 1);
547 * Start a hotwording session.
548 * @param {!hotword.constants.SessionSource} source Source of the hotword
550 * @param {!function()} startedCb Callback invoked when the session has
551 * been started successfully.
552 * @param {!function()} triggerCb Callback invoked when the hotword has
553 * @param {function()=} modelSavedCb Callback invoked when the speaker model
555 * @param {hotword.constants.RecognizerStartMode=} opt_mode The mode to
556 * start the recognizer in.
558 startSession: function(source, startedCb, triggerCb,
559 opt_modelSavedCb, opt_mode) {
560 if (this.isTrainingEnabled() && opt_mode) {
561 this.startMode_ = opt_mode;
563 this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
565 hotword.debug('Starting session for source: ' + source);
566 this.removeSession_(source);
567 this.sessions_.push(new Session_(source, triggerCb, startedCb,
569 this.updateStateFromStatus_();
573 * Stops a hotwording session.
574 * @param {!hotword.constants.SessionSource} source Source of the hotword
577 stopSession: function(source) {
578 hotword.debug('Stopping session for source: ' + source);
579 this.removeSession_(source);
580 // If this is a training session then switch the start mode back to
582 if (source == hotword.constants.SessionSource.TRAINING)
583 this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
584 this.updateStateFromStatus_();
588 * Handles a chrome.idle.onStateChanged event.
589 * @param {!string} state State, one of "active", "idle", or "locked".
592 handleIdleStateChanged_: function(state) {
593 hotword.debug('Idle state changed: ' + state);
594 var oldLocked = this.isLocked_;
595 if (state == 'locked')
596 this.isLocked_ = true;
598 this.isLocked_ = false;
600 if (oldLocked != this.isLocked_)
601 this.updateStateFromStatus_();
606 StateManager: StateManager