ozone: evdev: Sync caps lock LED state to evdev
[chromium-blink-merge.git] / chrome / browser / resources / hotword / state_manager.js
blob89148cade48873b24d6120fa1cb35d46dbf9d88d
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
22 function Session_(source, triggerCb, startedCb, opt_modelSavedCb) {
23 /**
24 * Source of the hotword session request.
25 * @private {!hotword.constants.SessionSource}
27 this.source_ = source;
29 /**
30 * Callback invoked when the hotword has triggered.
31 * @private {!function()}
33 this.triggerCb_ = triggerCb;
35 /**
36 * Callback invoked when the session has been started successfully.
37 * @private {?function()}
39 this.startedCb_ = startedCb;
41 /**
42 * Callback invoked when the session has been started successfully.
43 * @private {?function()}
45 this.speakerModelSavedCb_ = opt_modelSavedCb;
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
54 function StateManager() {
55 /**
56 * Current state.
57 * @private {hotword.StateManager.State_}
59 this.state_ = State_.STOPPED;
61 /**
62 * Current hotwording status.
63 * @private {?chrome.hotwordPrivate.StatusDetails}
65 this.hotwordStatus_ = null;
67 /**
68 * NaCl plugin manager.
69 * @private {?hotword.NaClManager}
71 this.pluginManager_ = null;
73 /**
74 * Currently active hotwording sessions.
75 * @private {!Array<Session_>}
77 this.sessions_ = [];
79 /**
80 * The mode to start the recognizer in.
81 * @private {!hotword.constants.RecognizerStartMode}
83 this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
85 /**
86 * Event that fires when the hotwording status has changed.
87 * @type {!ChromeEvent}
89 this.onStatusChanged = new chrome.Event();
91 /**
92 * Hotword trigger audio notification... a.k.a The Chime (tm).
93 * @private {!HTMLAudioElement}
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
103 this.idleStateChangedListener_ = this.handleIdleStateChanged_.bind(this);
106 * Whether this user is locked.
107 * @private {boolean}
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.
115 * @private {boolean}
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_);
147 * @enum {number}
148 * @private
150 StateManager.State_ = {
151 STOPPED: 0,
152 STARTING: 1,
153 RUNNING: 2,
154 ERROR: 3,
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
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
234 * status.
235 * @private
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.
247 * @private
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.hotwordStatus_.userIsActive) {
271 // If the user is no longer the active user, we need to shut down the
272 // detector so that we're no longer using the microphone. As a result,
273 // the microphone indicator in the task bar is not shown.
274 this.shutdownDetector_();
275 } else if (this.sessions_.length && !this.isLocked_) {
276 this.startDetector_();
277 } else {
278 this.stopDetector_();
281 if (!chrome.idle.onStateChanged.hasListener(
282 this.idleStateChangedListener_)) {
283 chrome.idle.onStateChanged.addListener(
284 this.idleStateChangedListener_);
286 } else {
287 // Not enabled. Shut down if running.
288 this.shutdownDetector_();
290 chrome.idle.onStateChanged.removeListener(
291 this.idleStateChangedListener_);
296 * Starts the hotword detector.
297 * @private
299 startDetector_: function() {
300 // Last attempt to start detector resulted in an error.
301 if (this.state_ == State_.ERROR) {
302 // TODO(amistry): Do some error rate tracking here and disable the
303 // extension if we error too often.
306 if (!this.pluginManager_) {
307 this.state_ = State_.STARTING;
308 this.pluginManager_ = new hotword.NaClManager(this.loggingEnabled_);
309 this.pluginManager_.addEventListener(hotword.constants.Event.READY,
310 this.onReady_.bind(this));
311 this.pluginManager_.addEventListener(hotword.constants.Event.ERROR,
312 this.onError_.bind(this));
313 this.pluginManager_.addEventListener(hotword.constants.Event.TRIGGER,
314 this.onTrigger_.bind(this));
315 this.pluginManager_.addEventListener(
316 hotword.constants.Event.SPEAKER_MODEL_SAVED,
317 this.onSpeakerModelSaved_.bind(this));
318 chrome.runtime.getPlatformInfo(function(platform) {
319 var naclArch = platform.nacl_arch;
321 // googDucking set to false so that audio output level from other tabs
322 // is not affected when hotword is enabled. https://crbug.com/357773
323 // content/common/media/media_stream_options.cc
324 // When always-on is enabled, request the hotword stream.
325 // Optional because we allow power users to bypass the hardware
326 // detection via a flag, and hence the hotword stream may not be
327 // available.
328 var constraints = /** @type {googMediaStreamConstraints} */
329 ({audio: {optional: [
330 { googDucking: false },
331 { googHotword: this.isAlwaysOnEnabled() }
332 ]}});
333 navigator.webkitGetUserMedia(
334 /** @type {MediaStreamConstraints} */ (constraints),
335 function(stream) {
336 hotword.metrics.recordEnum(
337 hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
338 hotword.constants.UmaMediaStreamOpenResult.SUCCESS,
339 hotword.constants.UmaMediaStreamOpenResult.MAX);
340 if (this.isAlwaysOnEnabled())
341 this.keepAlive_.start();
342 if (!this.pluginManager_.initialize(naclArch, stream)) {
343 this.state_ = State_.ERROR;
344 this.shutdownPluginManager_();
346 }.bind(this),
347 function(error) {
348 if (error.name in UmaMediaStreamOpenResults_) {
349 var metricValue = UmaMediaStreamOpenResults_[error.name];
350 } else {
351 var metricValue =
352 hotword.constants.UmaMediaStreamOpenResult.UNKNOWN;
354 hotword.metrics.recordEnum(
355 hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
356 metricValue,
357 hotword.constants.UmaMediaStreamOpenResult.MAX);
358 this.state_ = State_.ERROR;
359 this.pluginManager_ = null;
360 }.bind(this));
361 }.bind(this));
362 } else if (this.state_ != State_.STARTING) {
363 // Don't try to start a starting detector.
364 this.startRecognizer_();
369 * Start the recognizer plugin. Assumes the plugin has been loaded and is
370 * ready to start.
371 * @private
373 startRecognizer_: function() {
374 assert(this.pluginManager_, 'No NaCl plugin loaded');
375 if (this.state_ != State_.RUNNING) {
376 this.state_ = State_.RUNNING;
377 if (this.isAlwaysOnEnabled())
378 this.keepAlive_.start();
379 this.pluginManager_.startRecognizer(this.startMode_);
381 for (var i = 0; i < this.sessions_.length; i++) {
382 var session = this.sessions_[i];
383 if (session.startedCb_) {
384 session.startedCb_();
385 session.startedCb_ = null;
391 * Stops the hotword detector, if it's running.
392 * @private
394 stopDetector_: function() {
395 this.keepAlive_.stop();
396 if (this.pluginManager_ && this.state_ == State_.RUNNING) {
397 this.state_ = State_.STOPPED;
398 this.pluginManager_.stopRecognizer();
403 * Shuts down and removes the plugin manager, if it exists.
404 * @private
406 shutdownPluginManager_: function() {
407 this.keepAlive_.stop();
408 if (this.pluginManager_) {
409 this.pluginManager_.shutdown();
410 this.pluginManager_ = null;
415 * Shuts down the hotword detector.
416 * @private
418 shutdownDetector_: function() {
419 this.state_ = State_.STOPPED;
420 this.shutdownPluginManager_();
424 * Finalizes the speaker model. Assumes the plugin has been loaded and
425 * started.
427 finalizeSpeakerModel: function() {
428 assert(this.pluginManager_,
429 'Cannot finalize speaker model: No NaCl plugin loaded');
430 if (this.state_ != State_.RUNNING) {
431 hotword.debug('Cannot finalize speaker model: NaCl plugin not started');
432 return;
434 this.pluginManager_.finalizeSpeakerModel();
438 * Handle the hotword plugin being ready to start.
439 * @private
441 onReady_: function() {
442 if (this.state_ != State_.STARTING) {
443 // At this point, we should not be in the RUNNING state. Doing so would
444 // imply the hotword detector was started without being ready.
445 assert(this.state_ != State_.RUNNING, 'Unexpected RUNNING state');
446 this.shutdownPluginManager_();
447 return;
449 this.startRecognizer_();
453 * Handle an error from the hotword plugin.
454 * @private
456 onError_: function() {
457 this.state_ = State_.ERROR;
458 this.shutdownPluginManager_();
462 * Handle hotword triggering.
463 * @param {!Event} event Event containing audio log data.
464 * @private
466 onTrigger_: function(event) {
467 this.keepAlive_.stop();
468 hotword.debug('Hotword triggered!');
469 chrome.metricsPrivate.recordUserAction(
470 hotword.constants.UmaMetrics.TRIGGER);
471 assert(this.pluginManager_, 'No NaCl plugin loaded on trigger');
472 // Detector implicitly stops when the hotword is detected.
473 this.state_ = State_.STOPPED;
475 // Play the chime.
476 this.chime_.play();
478 // Implicitly clear the top session. A session needs to be started in
479 // order to restart the detector.
480 if (this.sessions_.length) {
481 var session = this.sessions_.pop();
482 session.triggerCb_(event.log);
484 hotword.metrics.recordEnum(
485 hotword.constants.UmaMetrics.TRIGGER_SOURCE,
486 UmaTriggerSources_[session.source_],
487 hotword.constants.UmaTriggerSource.MAX);
490 // If we're in always-on mode, shut down the hotword detector. The hotword
491 // stream requires that we close and re-open it after a trigger, and the
492 // only way to accomplish this is to shut everything down.
493 if (this.isAlwaysOnEnabled())
494 this.shutdownDetector_();
498 * Handle speaker model saved.
499 * @private
501 onSpeakerModelSaved_: function() {
502 hotword.debug('Speaker model saved!');
504 if (this.sessions_.length) {
505 // Only call the callback of the the top session.
506 var session = this.sessions_[this.sessions_.length - 1];
507 if (session.speakerModelSavedCb_)
508 session.speakerModelSavedCb_();
513 * Remove a hotwording session from the given source.
514 * @param {!hotword.constants.SessionSource} source Source of the hotword
515 * session request.
516 * @private
518 removeSession_: function(source) {
519 for (var i = 0; i < this.sessions_.length; i++) {
520 if (this.sessions_[i].source_ == source) {
521 this.sessions_.splice(i, 1);
522 break;
528 * Start a hotwording session.
529 * @param {!hotword.constants.SessionSource} source Source of the hotword
530 * session request.
531 * @param {!function()} startedCb Callback invoked when the session has
532 * been started successfully.
533 * @param {!function()} triggerCb Callback invoked when the hotword has
534 * @param {function()=} modelSavedCb Callback invoked when the speaker model
535 * has been saved.
536 * @param {hotword.constants.RecognizerStartMode=} opt_mode The mode to
537 * start the recognizer in.
539 startSession: function(source, startedCb, triggerCb,
540 opt_modelSavedCb, opt_mode) {
541 if (this.isTrainingEnabled() && opt_mode) {
542 this.startMode_ = opt_mode;
543 } else {
544 this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
546 hotword.debug('Starting session for source: ' + source);
547 this.removeSession_(source);
548 this.sessions_.push(new Session_(source, triggerCb, startedCb,
549 opt_modelSavedCb));
550 this.updateStateFromStatus_();
554 * Stops a hotwording session.
555 * @param {!hotword.constants.SessionSource} source Source of the hotword
556 * session request.
558 stopSession: function(source) {
559 hotword.debug('Stopping session for source: ' + source);
560 this.removeSession_(source);
561 // If this is a training session then switch the start mode back to
562 // normal.
563 if (source == hotword.constants.SessionSource.TRAINING)
564 this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
565 this.updateStateFromStatus_();
569 * Handles a chrome.idle.onStateChanged event.
570 * @param {!string} state State, one of "active", "idle", or "locked".
571 * @private
573 handleIdleStateChanged_: function(state) {
574 hotword.debug('Idle state changed: ' + state);
575 var oldLocked = this.isLocked_;
576 if (state == 'locked')
577 this.isLocked_ = true;
578 else
579 this.isLocked_ = false;
581 if (oldLocked != this.isLocked_)
582 this.updateStateFromStatus_();
586 return {
587 StateManager: StateManager