Remove the old signature of NotificationManager::closePersistent().
[chromium-blink-merge.git] / chrome / browser / resources / hotword / state_manager.js
blob9a42b1b63b75298ec5f32806f1930c118c511e87
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);
105     /**
106      * Whether this user is locked.
107      * @private {boolean}
108      */
109     this.isLocked_ = false;
111     /**
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.
115      * @private {boolean}
116      */
117     this.loggingEnabled_ = false;
119     /**
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}
124      */
125     this.trainingEnabled_ = false;
127     /**
128      * Helper class to keep this extension alive while the hotword detector is
129      * running in always-on mode.
130      * @private {!hotword.KeepAlive}
131      */
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_);
144   }
146   /**
147    * @enum {number}
148    * @private
149    */
150   StateManager.State_ = {
151     STOPPED: 0,
152     STARTING: 1,
153     RUNNING: 2,
154     ERROR: 3,
155   };
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
161     'NotSupportedError':
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,
178     'InvalidStateError':
179         hotword.constants.UmaMediaStreamOpenResult.INVALID_STATE,
180     'DevicesNotFoundError':
181         hotword.constants.UmaMediaStreamOpenResult.DEVICES_NOT_FOUND,
182     'InvalidSecurityOriginError':
183         hotword.constants.UmaMediaStreamOpenResult.INVALID_SECURITY_ORIGIN
184   };
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
191   };
193   StateManager.prototype = {
194     /**
195      * Request status details update. Intended to be called from the
196      * hotwordPrivate.onEnabledChanged() event.
197      */
198     updateStatus: function() {
199       chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this));
200     },
202     /**
203      * @return {boolean} True if google.com/NTP/launcher hotwording is enabled.
204      */
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;
212     },
214     /**
215      * @return {boolean} True if always-on hotwording is enabled.
216      */
217     isAlwaysOnEnabled: function() {
218       assert(this.hotwordStatus_, 'No hotword status (isAlwaysOnEnabled)');
219       return this.hotwordStatus_.alwaysOnEnabled &&
220           !this.hotwordStatus_.trainingEnabled;
221     },
223     /**
224      * @return {boolean} True if training is enabled.
225      */
226     isTrainingEnabled: function() {
227       assert(this.hotwordStatus_, 'No hotword status (isTrainingEnabled)');
228       return this.hotwordStatus_.trainingEnabled;
229     },
231     /**
232      * Callback for hotwordPrivate.getStatus() function.
233      * @param {chrome.hotwordPrivate.StatusDetails} status Current hotword
234      *     status.
235      * @private
236      */
237     handleStatus_: function(status) {
238       hotword.debug('New hotword status', status);
239       this.hotwordStatus_ = status;
240       this.updateStateFromStatus_();
242       this.onStatusChanged.dispatch();
243     },
245     /**
246      * Updates state based on the current status.
247      * @private
248      */
249     updateStateFromStatus_: function() {
250       if (!this.hotwordStatus_)
251         return;
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
257         // has changed.
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_();
273         } else {
274           this.shutdownDetector_();
275         }
277         if (!chrome.idle.onStateChanged.hasListener(
278                 this.idleStateChangedListener_)) {
279           chrome.idle.onStateChanged.addListener(
280               this.idleStateChangedListener_);
281         }
282       } else {
283         // Not enabled. Shut down if running.
284         this.shutdownDetector_();
286         chrome.idle.onStateChanged.removeListener(
287             this.idleStateChangedListener_);
288       }
289     },
291     /**
292      * Starts the hotword detector.
293      * @private
294      */
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.
300       }
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
325           // available.
326           var constraints = /** @type {googMediaStreamConstraints} */
327               ({audio: {optional: [
328                 { googDucking: false },
329                 { googHotword: this.isAlwaysOnEnabled() }
330               ]}});
331           navigator.webkitGetUserMedia(
332               /** @type {MediaStreamConstraints} */ (constraints),
333               function(stream) {
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
339                 // finishes opening.
340                 if (this.pluginManager_ == null) {
341                   stream.getAudioTracks()[0].stop();
342                   return;
343                 }
345                 if (this.isAlwaysOnEnabled())
346                   this.keepAlive_.start();
347                 if (!this.pluginManager_.initialize(naclArch, stream)) {
348                   this.state_ = State_.ERROR;
349                   this.shutdownPluginManager_();
350                 }
351               }.bind(this),
352               function(error) {
353                 if (error.name in UmaMediaStreamOpenResults_) {
354                   var metricValue = UmaMediaStreamOpenResults_[error.name];
355                 } else {
356                   var metricValue =
357                       hotword.constants.UmaMediaStreamOpenResult.UNKNOWN;
358                 }
359                 hotword.metrics.recordEnum(
360                     hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
361                     metricValue,
362                     hotword.constants.UmaMediaStreamOpenResult.MAX);
363                 this.state_ = State_.ERROR;
364                 this.pluginManager_ = null;
365               }.bind(this));
366         }.bind(this));
367       } else if (this.state_ != State_.STARTING) {
368         // Don't try to start a starting detector.
369         this.startRecognizer_();
370       }
371     },
373     /**
374      * Start the recognizer plugin. Assumes the plugin has been loaded and is
375      * ready to start.
376      * @private
377      */
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_);
385       }
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;
391         }
392       }
393     },
395     /**
396      * Stops the hotword detector, if it's running.
397      * @private
398      */
399     stopDetector_: function() {
400       this.keepAlive_.stop();
401       if (this.pluginManager_ && this.state_ == State_.RUNNING) {
402         this.state_ = State_.STOPPED;
403         this.pluginManager_.stopRecognizer();
404       }
405     },
407     /**
408      * Shuts down and removes the plugin manager, if it exists.
409      * @private
410      */
411     shutdownPluginManager_: function() {
412       this.keepAlive_.stop();
413       if (this.pluginManager_) {
414         this.pluginManager_.shutdown();
415         this.pluginManager_ = null;
416       }
417     },
419     /**
420      * Shuts down the hotword detector.
421      * @private
422      */
423     shutdownDetector_: function() {
424       this.state_ = State_.STOPPED;
425       this.shutdownPluginManager_();
426     },
428     /**
429      * Finalizes the speaker model. Assumes the plugin has been loaded and
430      * started.
431      */
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');
437         return;
438       }
439       this.pluginManager_.finalizeSpeakerModel();
440     },
442     /**
443      * Handle the hotword plugin being ready to start.
444      * @private
445      */
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_();
452         return;
453       }
454       this.startRecognizer_();
455     },
457     /**
458      * Handle an error from the hotword plugin.
459      * @private
460      */
461     onError_: function() {
462       this.state_ = State_.ERROR;
463       this.shutdownPluginManager_();
464     },
466     /**
467      * Handle hotword triggering.
468      * @param {!Event} event Event containing audio log data.
469      * @private
470      */
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;
480       // Play the chime.
481       this.chime_.play();
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);
493       }
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_();
500     },
502     /**
503      * Handle hotword timeout.
504      * @private
505      */
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
511       // re-arm the DSP.
512       this.shutdownDetector_();
513       this.updateStateFromStatus_();
514     },
516     /**
517      * Handle speaker model saved.
518      * @private
519      */
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_();
528       }
529     },
531     /**
532      * Remove a hotwording session from the given source.
533      * @param {!hotword.constants.SessionSource} source Source of the hotword
534      *     session request.
535      * @private
536      */
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);
541           break;
542         }
543       }
544     },
546     /**
547      * Start a hotwording session.
548      * @param {!hotword.constants.SessionSource} source Source of the hotword
549      *     session request.
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
554      *     has been saved.
555      * @param {hotword.constants.RecognizerStartMode=} opt_mode The mode to
556      *     start the recognizer in.
557      */
558     startSession: function(source, startedCb, triggerCb,
559                            opt_modelSavedCb, opt_mode) {
560       if (this.isTrainingEnabled() && opt_mode) {
561         this.startMode_ = opt_mode;
562       } else {
563         this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
564       }
565       hotword.debug('Starting session for source: ' + source);
566       this.removeSession_(source);
567       this.sessions_.push(new Session_(source, triggerCb, startedCb,
568                                        opt_modelSavedCb));
569       this.updateStateFromStatus_();
570     },
572     /**
573      * Stops a hotwording session.
574      * @param {!hotword.constants.SessionSource} source Source of the hotword
575      *     session request.
576      */
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
581       // normal.
582       if (source == hotword.constants.SessionSource.TRAINING)
583         this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
584       this.updateStateFromStatus_();
585     },
587     /**
588      * Handles a chrome.idle.onStateChanged event.
589      * @param {!string} state State, one of "active", "idle", or "locked".
590      * @private
591      */
592     handleIdleStateChanged_: function(state) {
593       hotword.debug('Idle state changed: ' + state);
594       var oldLocked = this.isLocked_;
595       if (state == 'locked')
596         this.isLocked_ = true;
597       else
598         this.isLocked_ = false;
600       if (oldLocked != this.isLocked_)
601         this.updateStateFromStatus_();
602     }
603   };
605   return {
606     StateManager: StateManager
607   };