Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / chrome / browser / resources / hotword / state_manager.js
bloba7939b16321fbc0030e12f02b422270db749f624
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() {
6   'use strict';
8   /**
9    * Trivial container class for session information.
10    * @param {!hotword.constants.SessionSource} source Source of the hotword
11    *     session.
12    * @param {!function()} triggerCb Callback invoked when the hotword has
13    *     triggered.
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.
18    * @constructor
19    * @struct
20    * @private
21    */
22   function Session_(source, triggerCb, startedCb, opt_modelSavedCb) {
23     /**
24      * Source of the hotword session request.
25      * @private {!hotword.constants.SessionSource}
26      */
27     this.source_ = source;
29      /**
30       * Callback invoked when the hotword has triggered.
31       * @private {!function()}
32       */
33     this.triggerCb_ = triggerCb;
35     /**
36      * Callback invoked when the session has been started successfully.
37      * @private {?function()}
38      */
39     this.startedCb_ = startedCb;
41     /**
42      * Callback invoked when the session has been started successfully.
43      * @private {?function()}
44      */
45     this.speakerModelSavedCb_ = opt_modelSavedCb;
46   }
48   /**
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.
52    * @constructor
53    */
54   function StateManager() {
55     /**
56      * Current state.
57      * @private {hotword.StateManager.State_}
58      */
59     this.state_ = State_.STOPPED;
61     /**
62      * Current hotwording status.
63      * @private {?chrome.hotwordPrivate.StatusDetails}
64      */
65     this.hotwordStatus_ = null;
67     /**
68      * NaCl plugin manager.
69      * @private {?hotword.NaClManager}
70      */
71     this.pluginManager_ = null;
73     /**
74      * Currently active hotwording sessions.
75      * @private {!Array<Session_>}
76      */
77     this.sessions_ = [];
79     /**
80      * The mode to start the recognizer in.
81      * @private {!hotword.constants.RecognizerStartMode}
82      */
83     this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
85     /**
86      * Event that fires when the hotwording status has changed.
87      * @type {!ChromeEvent}
88      */
89     this.onStatusChanged = new chrome.Event();
91     /**
92      * Hotword trigger audio notification... a.k.a The Chime (tm).
93      * @private {!HTMLAudioElement}
94      */
95     this.chime_ =
96         /** @type {!HTMLAudioElement} */(document.createElement('audio'));
98     /**
99      * Chrome event listeners. Saved so that they can be de-registered when
100      * hotwording is disabled.
101      * @private
102      */
103     this.idleStateChangedListener_ = this.handleIdleStateChanged_.bind(this);
104     this.startupListener_ = this.handleStartup_.bind(this);
106     /**
107      * Whether this user is locked.
108      * @private {boolean}
109      */
110     this.isLocked_ = false;
112     /**
113      * Current state of audio logging.
114      * This is tracked separately from hotwordStatus_ because we need to restart
115      * the hotword detector when this value changes.
116      * @private {boolean}
117      */
118     this.loggingEnabled_ = false;
120     /**
121      * Current state of training.
122      * This is tracked separately from |hotwordStatus_| because we need to
123      * restart the hotword detector when this value changes.
124      * @private {!boolean}
125      */
126     this.trainingEnabled_ = false;
128     /**
129      * Helper class to keep this extension alive while the hotword detector is
130      * running in always-on mode.
131      * @private {!hotword.KeepAlive}
132      */
133     this.keepAlive_ = new hotword.KeepAlive();
135     // Get the initial status.
136     chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this));
138     // Setup the chime and insert into the page.
139     // Set preload=none to prevent an audio output stream from being created
140     // when the extension loads.
141     this.chime_.preload = 'none';
142     this.chime_.src = chrome.extension.getURL(
143         hotword.constants.SHARED_MODULE_ROOT + '/audio/chime.wav');
144     document.body.appendChild(this.chime_);
146     // In order to remove this listener, it must first be added. This handles
147     // the case on first Chrome startup where this event is never registered,
148     // so can't be removed when it's determined that hotwording is disabled.
149     // Why not only remove the listener if it exists? Extension API events have
150     // two parts to them, the Javascript listeners, and a browser-side component
151     // that wakes up the extension if it's an event page. The browser-side
152     // wake-up event is only removed when the number of javascript listeners
153     // becomes 0. To clear the browser wake-up event, a listener first needs to
154     // be added, then removed in order to drop the count to 0 and remove the
155     // event.
156     chrome.runtime.onStartup.addListener(this.startupListener_);
157   }
159   /**
160    * @enum {number}
161    * @private
162    */
163   StateManager.State_ = {
164     STOPPED: 0,
165     STARTING: 1,
166     RUNNING: 2,
167     ERROR: 3,
168   };
169   var State_ = StateManager.State_;
171   var UmaMediaStreamOpenResults_ = {
172     // These first error are defined by the MediaStream spec:
173     // http://w3c.github.io/mediacapture-main/getusermedia.html#idl-def-MediaStreamError
174     'NotSupportedError':
175         hotword.constants.UmaMediaStreamOpenResult.NOT_SUPPORTED,
176     'PermissionDeniedError':
177         hotword.constants.UmaMediaStreamOpenResult.PERMISSION_DENIED,
178     'ConstraintNotSatisfiedError':
179         hotword.constants.UmaMediaStreamOpenResult.CONSTRAINT_NOT_SATISFIED,
180     'OverconstrainedError':
181         hotword.constants.UmaMediaStreamOpenResult.OVERCONSTRAINED,
182     'NotFoundError': hotword.constants.UmaMediaStreamOpenResult.NOT_FOUND,
183     'AbortError': hotword.constants.UmaMediaStreamOpenResult.ABORT,
184     'SourceUnavailableError':
185         hotword.constants.UmaMediaStreamOpenResult.SOURCE_UNAVAILABLE,
186     // The next few errors are chrome-specific. See:
187     // content/renderer/media/user_media_client_impl.cc
188     // (UserMediaClientImpl::GetUserMediaRequestFailed)
189     'PermissionDismissedError':
190         hotword.constants.UmaMediaStreamOpenResult.PERMISSION_DISMISSED,
191     'InvalidStateError':
192         hotword.constants.UmaMediaStreamOpenResult.INVALID_STATE,
193     'DevicesNotFoundError':
194         hotword.constants.UmaMediaStreamOpenResult.DEVICES_NOT_FOUND,
195     'InvalidSecurityOriginError':
196         hotword.constants.UmaMediaStreamOpenResult.INVALID_SECURITY_ORIGIN
197   };
199   var UmaTriggerSources_ = {
200     'launcher': hotword.constants.UmaTriggerSource.LAUNCHER,
201     'ntp': hotword.constants.UmaTriggerSource.NTP_GOOGLE_COM,
202     'always': hotword.constants.UmaTriggerSource.ALWAYS_ON,
203     'training': hotword.constants.UmaTriggerSource.TRAINING
204   };
206   StateManager.prototype = {
207     /**
208      * Request status details update. Intended to be called from the
209      * hotwordPrivate.onEnabledChanged() event.
210      */
211     updateStatus: function() {
212       chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this));
213     },
215     /**
216      * @return {boolean} True if google.com/NTP/launcher hotwording is enabled.
217      */
218     isSometimesOnEnabled: function() {
219       assert(this.hotwordStatus_,
220              'No hotwording status (isSometimesOnEnabled)');
221       // Although the two settings are supposed to be mutually exclusive, it's
222       // possible for both to be set. In that case, always-on takes precedence.
223       return this.hotwordStatus_.enabled &&
224           !this.hotwordStatus_.alwaysOnEnabled;
225     },
227     /**
228      * @return {boolean} True if always-on hotwording is enabled.
229      */
230     isAlwaysOnEnabled: function() {
231       assert(this.hotwordStatus_, 'No hotword status (isAlwaysOnEnabled)');
232       return this.hotwordStatus_.alwaysOnEnabled &&
233           !this.hotwordStatus_.trainingEnabled;
234     },
236     /**
237      * @return {boolean} True if training is enabled.
238      */
239     isTrainingEnabled: function() {
240       assert(this.hotwordStatus_, 'No hotword status (isTrainingEnabled)');
241       return this.hotwordStatus_.trainingEnabled;
242     },
244     /**
245      * Callback for hotwordPrivate.getStatus() function.
246      * @param {chrome.hotwordPrivate.StatusDetails} status Current hotword
247      *     status.
248      * @private
249      */
250     handleStatus_: function(status) {
251       hotword.debug('New hotword status', status);
252       this.hotwordStatus_ = status;
253       this.updateStateFromStatus_();
255       this.onStatusChanged.dispatch();
256     },
258     /**
259      * Updates state based on the current status.
260      * @private
261      */
262     updateStateFromStatus_: function() {
263       if (!this.hotwordStatus_)
264         return;
266       if (this.hotwordStatus_.enabled ||
267           this.hotwordStatus_.alwaysOnEnabled ||
268           this.hotwordStatus_.trainingEnabled) {
269         // Detect changes to audio logging and kill the detector if that setting
270         // has changed.
271         if (this.hotwordStatus_.audioLoggingEnabled != this.loggingEnabled_)
272           this.shutdownDetector_();
273         this.loggingEnabled_ = this.hotwordStatus_.audioLoggingEnabled;
275         // If the training state has changed, we need to first shut down the
276         // detector so that we can restart in a different mode.
277         if (this.hotwordStatus_.trainingEnabled != this.trainingEnabled_)
278           this.shutdownDetector_();
279         this.trainingEnabled_ = this.hotwordStatus_.trainingEnabled;
281         // Start the detector if there's a session and the user is unlocked, and
282         // stops it otherwise.
283         if (this.sessions_.length && !this.isLocked_ &&
284             this.hotwordStatus_.userIsActive) {
285           this.startDetector_();
286         } else {
287           this.shutdownDetector_();
288         }
290         if (!chrome.idle.onStateChanged.hasListener(
291                 this.idleStateChangedListener_)) {
292           chrome.idle.onStateChanged.addListener(
293               this.idleStateChangedListener_);
294         }
295         if (!chrome.runtime.onStartup.hasListener(this.startupListener_))
296           chrome.runtime.onStartup.addListener(this.startupListener_);
297       } else {
298         // Not enabled. Shut down if running.
299         this.shutdownDetector_();
301         chrome.idle.onStateChanged.removeListener(
302             this.idleStateChangedListener_);
303         // If hotwording isn't enabled, don't start this component extension on
304         // Chrome startup. If a user enables hotwording, the status change
305         // event will be fired and the onStartup event will be registered.
306         chrome.runtime.onStartup.removeListener(this.startupListener_);
307       }
308     },
310     /**
311      * Starts the hotword detector.
312      * @private
313      */
314     startDetector_: function() {
315       // Last attempt to start detector resulted in an error.
316       if (this.state_ == State_.ERROR) {
317         // TODO(amistry): Do some error rate tracking here and disable the
318         // extension if we error too often.
319       }
321       if (!this.pluginManager_) {
322         this.state_ = State_.STARTING;
323         var isHotwordStream = this.isAlwaysOnEnabled() &&
324             this.hotwordStatus_.hotwordHardwareAvailable;
325         this.pluginManager_ = new hotword.NaClManager(this.loggingEnabled_,
326                                                       isHotwordStream);
327         this.pluginManager_.addEventListener(hotword.constants.Event.READY,
328                                              this.onReady_.bind(this));
329         this.pluginManager_.addEventListener(hotword.constants.Event.ERROR,
330                                              this.onError_.bind(this));
331         this.pluginManager_.addEventListener(hotword.constants.Event.TRIGGER,
332                                              this.onTrigger_.bind(this));
333         this.pluginManager_.addEventListener(hotword.constants.Event.TIMEOUT,
334                                              this.onTimeout_.bind(this));
335         this.pluginManager_.addEventListener(
336             hotword.constants.Event.SPEAKER_MODEL_SAVED,
337             this.onSpeakerModelSaved_.bind(this));
338         chrome.runtime.getPlatformInfo(function(platform) {
339           var naclArch = platform.nacl_arch;
341           // googDucking set to false so that audio output level from other tabs
342           // is not affected when hotword is enabled. https://crbug.com/357773
343           // content/common/media/media_stream_options.cc
344           // When always-on is enabled, request the hotword stream.
345           // Optional because we allow power users to bypass the hardware
346           // detection via a flag, and hence the hotword stream may not be
347           // available.
348           var constraints = /** @type {googMediaStreamConstraints} */
349               ({audio: {optional: [
350                 { googDucking: false },
351                 { googHotword: this.isAlwaysOnEnabled() }
352               ]}});
353           navigator.webkitGetUserMedia(
354               /** @type {MediaStreamConstraints} */ (constraints),
355               function(stream) {
356                 hotword.metrics.recordEnum(
357                     hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
358                     hotword.constants.UmaMediaStreamOpenResult.SUCCESS,
359                     hotword.constants.UmaMediaStreamOpenResult.MAX);
360                 // The detector could have been shut down before the stream
361                 // finishes opening.
362                 if (this.pluginManager_ == null) {
363                   stream.getAudioTracks()[0].stop();
364                   return;
365                 }
367                 if (this.isAlwaysOnEnabled())
368                   this.keepAlive_.start();
369                 if (!this.pluginManager_.initialize(naclArch, stream)) {
370                   this.state_ = State_.ERROR;
371                   this.shutdownPluginManager_();
372                 }
373               }.bind(this),
374               function(error) {
375                 if (error.name in UmaMediaStreamOpenResults_) {
376                   var metricValue = UmaMediaStreamOpenResults_[error.name];
377                 } else {
378                   var metricValue =
379                       hotword.constants.UmaMediaStreamOpenResult.UNKNOWN;
380                 }
381                 hotword.metrics.recordEnum(
382                     hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
383                     metricValue,
384                     hotword.constants.UmaMediaStreamOpenResult.MAX);
385                 this.state_ = State_.ERROR;
386                 this.pluginManager_ = null;
387               }.bind(this));
388         }.bind(this));
389       } else if (this.state_ != State_.STARTING) {
390         // Don't try to start a starting detector.
391         this.startRecognizer_();
392       }
393     },
395     /**
396      * Start the recognizer plugin. Assumes the plugin has been loaded and is
397      * ready to start.
398      * @private
399      */
400     startRecognizer_: function() {
401       assert(this.pluginManager_, 'No NaCl plugin loaded');
402       if (this.state_ != State_.RUNNING) {
403         this.state_ = State_.RUNNING;
404         if (this.isAlwaysOnEnabled())
405           this.keepAlive_.start();
406         this.pluginManager_.startRecognizer(this.startMode_);
407       }
408       for (var i = 0; i < this.sessions_.length; i++) {
409         var session = this.sessions_[i];
410         if (session.startedCb_) {
411           session.startedCb_();
412           session.startedCb_ = null;
413         }
414       }
415     },
417     /**
418      * Stops the hotword detector, if it's running.
419      * @private
420      */
421     stopDetector_: function() {
422       this.keepAlive_.stop();
423       if (this.pluginManager_ && this.state_ == State_.RUNNING) {
424         this.state_ = State_.STOPPED;
425         this.pluginManager_.stopRecognizer();
426       }
427     },
429     /**
430      * Shuts down and removes the plugin manager, if it exists.
431      * @private
432      */
433     shutdownPluginManager_: function() {
434       this.keepAlive_.stop();
435       if (this.pluginManager_) {
436         this.pluginManager_.shutdown();
437         this.pluginManager_ = null;
438       }
439     },
441     /**
442      * Shuts down the hotword detector.
443      * @private
444      */
445     shutdownDetector_: function() {
446       this.state_ = State_.STOPPED;
447       this.shutdownPluginManager_();
448     },
450     /**
451      * Finalizes the speaker model. Assumes the plugin has been loaded and
452      * started.
453      */
454     finalizeSpeakerModel: function() {
455       assert(this.pluginManager_,
456              'Cannot finalize speaker model: No NaCl plugin loaded');
457       if (this.state_ != State_.RUNNING) {
458         hotword.debug('Cannot finalize speaker model: NaCl plugin not started');
459         return;
460       }
461       this.pluginManager_.finalizeSpeakerModel();
462     },
464     /**
465      * Handle the hotword plugin being ready to start.
466      * @private
467      */
468     onReady_: function() {
469       if (this.state_ != State_.STARTING) {
470         // At this point, we should not be in the RUNNING state. Doing so would
471         // imply the hotword detector was started without being ready.
472         assert(this.state_ != State_.RUNNING, 'Unexpected RUNNING state');
473         this.shutdownPluginManager_();
474         return;
475       }
476       this.startRecognizer_();
477     },
479     /**
480      * Handle an error from the hotword plugin.
481      * @private
482      */
483     onError_: function() {
484       this.state_ = State_.ERROR;
485       this.shutdownPluginManager_();
486     },
488     /**
489      * Handle hotword triggering.
490      * @param {!Event} event Event containing audio log data.
491      * @private
492      */
493     onTrigger_: function(event) {
494       this.keepAlive_.stop();
495       hotword.debug('Hotword triggered!');
496       chrome.metricsPrivate.recordUserAction(
497           hotword.constants.UmaMetrics.TRIGGER);
498       assert(this.pluginManager_, 'No NaCl plugin loaded on trigger');
499       // Detector implicitly stops when the hotword is detected.
500       this.state_ = State_.STOPPED;
502       // Play the chime.
503       this.chime_.play();
505       // Implicitly clear the top session. A session needs to be started in
506       // order to restart the detector.
507       if (this.sessions_.length) {
508         var session = this.sessions_.pop();
509         session.triggerCb_(event.log);
511         hotword.metrics.recordEnum(
512             hotword.constants.UmaMetrics.TRIGGER_SOURCE,
513             UmaTriggerSources_[session.source_],
514             hotword.constants.UmaTriggerSource.MAX);
515       }
517       // If we're in always-on mode, shut down the hotword detector. The hotword
518       // stream requires that we close and re-open it after a trigger, and the
519       // only way to accomplish this is to shut everything down.
520       if (this.isAlwaysOnEnabled())
521         this.shutdownDetector_();
522     },
524     /**
525      * Handle hotword timeout.
526      * @private
527      */
528     onTimeout_: function() {
529       hotword.debug('Hotword timeout!');
531       // We get this event when the hotword detector thinks there's a false
532       // trigger. In this case, we need to shut down and restart the detector to
533       // re-arm the DSP.
534       this.shutdownDetector_();
535       this.updateStateFromStatus_();
536     },
538     /**
539      * Handle speaker model saved.
540      * @private
541      */
542     onSpeakerModelSaved_: function() {
543       hotword.debug('Speaker model saved!');
545       if (this.sessions_.length) {
546         // Only call the callback of the the top session.
547         var session = this.sessions_[this.sessions_.length - 1];
548         if (session.speakerModelSavedCb_)
549           session.speakerModelSavedCb_();
550       }
551     },
553     /**
554      * Remove a hotwording session from the given source.
555      * @param {!hotword.constants.SessionSource} source Source of the hotword
556      *     session request.
557      * @private
558      */
559     removeSession_: function(source) {
560       for (var i = 0; i < this.sessions_.length; i++) {
561         if (this.sessions_[i].source_ == source) {
562           this.sessions_.splice(i, 1);
563           break;
564         }
565       }
566     },
568     /**
569      * Start a hotwording session.
570      * @param {!hotword.constants.SessionSource} source Source of the hotword
571      *     session request.
572      * @param {!function()} startedCb Callback invoked when the session has
573      *     been started successfully.
574      * @param {!function()} triggerCb Callback invoked when the hotword has
575      * @param {function()=} modelSavedCb Callback invoked when the speaker model
576      *     has been saved.
577      * @param {hotword.constants.RecognizerStartMode=} opt_mode The mode to
578      *     start the recognizer in.
579      */
580     startSession: function(source, startedCb, triggerCb,
581                            opt_modelSavedCb, opt_mode) {
582       if (this.isTrainingEnabled() && opt_mode) {
583         this.startMode_ = opt_mode;
584       } else {
585         this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
586       }
587       hotword.debug('Starting session for source: ' + source);
588       this.removeSession_(source);
589       this.sessions_.push(new Session_(source, triggerCb, startedCb,
590                                        opt_modelSavedCb));
591       this.updateStateFromStatus_();
592     },
594     /**
595      * Stops a hotwording session.
596      * @param {!hotword.constants.SessionSource} source Source of the hotword
597      *     session request.
598      */
599     stopSession: function(source) {
600       hotword.debug('Stopping session for source: ' + source);
601       this.removeSession_(source);
602       // If this is a training session then switch the start mode back to
603       // normal.
604       if (source == hotword.constants.SessionSource.TRAINING)
605         this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
606       this.updateStateFromStatus_();
607     },
609     /**
610      * Handles a chrome.idle.onStateChanged event.
611      * @param {!string} state State, one of "active", "idle", or "locked".
612      * @private
613      */
614     handleIdleStateChanged_: function(state) {
615       hotword.debug('Idle state changed: ' + state);
616       var oldLocked = this.isLocked_;
617       if (state == 'locked')
618         this.isLocked_ = true;
619       else
620         this.isLocked_ = false;
622       if (oldLocked != this.isLocked_)
623         this.updateStateFromStatus_();
624     },
626     /**
627      * Handles a chrome.runtime.onStartup event.
628      * @private
629      */
630     handleStartup_: function() {
631       // Nothing specific needs to be done here. This function exists solely to
632       // be registered on the startup event.
633     }
634   };
636   return {
637     StateManager: StateManager
638   };