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);
104 this.startupListener_ = this.handleStartup_.bind(this);
107 * Whether this user is locked.
110 this.isLocked_ = false;
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.
118 this.loggingEnabled_ = false;
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}
126 this.trainingEnabled_ = false;
129 * Helper class to keep this extension alive while the hotword detector is
130 * running in always-on mode.
131 * @private {!hotword.KeepAlive}
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
156 chrome.runtime.onStartup.addListener(this.startupListener_);
163 StateManager.State_ = {
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
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,
192 hotword.constants.UmaMediaStreamOpenResult.INVALID_STATE,
193 'DevicesNotFoundError':
194 hotword.constants.UmaMediaStreamOpenResult.DEVICES_NOT_FOUND,
195 'InvalidSecurityOriginError':
196 hotword.constants.UmaMediaStreamOpenResult.INVALID_SECURITY_ORIGIN
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
206 StateManager.prototype = {
208 * Request status details update. Intended to be called from the
209 * hotwordPrivate.onEnabledChanged() event.
211 updateStatus: function() {
212 chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this));
216 * @return {boolean} True if google.com/NTP/launcher hotwording is enabled.
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;
228 * @return {boolean} True if always-on hotwording is enabled.
230 isAlwaysOnEnabled: function() {
231 assert(this.hotwordStatus_, 'No hotword status (isAlwaysOnEnabled)');
232 return this.hotwordStatus_.alwaysOnEnabled &&
233 !this.hotwordStatus_.trainingEnabled;
237 * @return {boolean} True if training is enabled.
239 isTrainingEnabled: function() {
240 assert(this.hotwordStatus_, 'No hotword status (isTrainingEnabled)');
241 return this.hotwordStatus_.trainingEnabled;
245 * Callback for hotwordPrivate.getStatus() function.
246 * @param {chrome.hotwordPrivate.StatusDetails} status Current hotword
250 handleStatus_: function(status) {
251 hotword.debug('New hotword status', status);
252 this.hotwordStatus_ = status;
253 this.updateStateFromStatus_();
255 this.onStatusChanged.dispatch();
259 * Updates state based on the current status.
262 updateStateFromStatus_: function() {
263 if (!this.hotwordStatus_)
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
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_();
287 this.shutdownDetector_();
290 if (!chrome.idle.onStateChanged.hasListener(
291 this.idleStateChangedListener_)) {
292 chrome.idle.onStateChanged.addListener(
293 this.idleStateChangedListener_);
295 if (!chrome.runtime.onStartup.hasListener(this.startupListener_))
296 chrome.runtime.onStartup.addListener(this.startupListener_);
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_);
311 * Starts the hotword detector.
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.
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_,
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
348 var constraints = /** @type {googMediaStreamConstraints} */
349 ({audio: {optional: [
350 { googDucking: false },
351 { googHotword: this.isAlwaysOnEnabled() }
353 navigator.webkitGetUserMedia(
354 /** @type {MediaStreamConstraints} */ (constraints),
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
362 if (this.pluginManager_ == null) {
363 stream.getAudioTracks()[0].stop();
367 if (this.isAlwaysOnEnabled())
368 this.keepAlive_.start();
369 if (!this.pluginManager_.initialize(naclArch, stream)) {
370 this.state_ = State_.ERROR;
371 this.shutdownPluginManager_();
375 if (error.name in UmaMediaStreamOpenResults_) {
376 var metricValue = UmaMediaStreamOpenResults_[error.name];
379 hotword.constants.UmaMediaStreamOpenResult.UNKNOWN;
381 hotword.metrics.recordEnum(
382 hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT,
384 hotword.constants.UmaMediaStreamOpenResult.MAX);
385 this.state_ = State_.ERROR;
386 this.pluginManager_ = null;
389 } else if (this.state_ != State_.STARTING) {
390 // Don't try to start a starting detector.
391 this.startRecognizer_();
396 * Start the recognizer plugin. Assumes the plugin has been loaded and is
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_);
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;
418 * Stops the hotword detector, if it's running.
421 stopDetector_: function() {
422 this.keepAlive_.stop();
423 if (this.pluginManager_ && this.state_ == State_.RUNNING) {
424 this.state_ = State_.STOPPED;
425 this.pluginManager_.stopRecognizer();
430 * Shuts down and removes the plugin manager, if it exists.
433 shutdownPluginManager_: function() {
434 this.keepAlive_.stop();
435 if (this.pluginManager_) {
436 this.pluginManager_.shutdown();
437 this.pluginManager_ = null;
442 * Shuts down the hotword detector.
445 shutdownDetector_: function() {
446 this.state_ = State_.STOPPED;
447 this.shutdownPluginManager_();
451 * Finalizes the speaker model. Assumes the plugin has been loaded and
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');
461 this.pluginManager_.finalizeSpeakerModel();
465 * Handle the hotword plugin being ready to start.
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_();
476 this.startRecognizer_();
480 * Handle an error from the hotword plugin.
483 onError_: function() {
484 this.state_ = State_.ERROR;
485 this.shutdownPluginManager_();
489 * Handle hotword triggering.
490 * @param {!Event} event Event containing audio log data.
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;
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);
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_();
525 * Handle hotword timeout.
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
534 this.shutdownDetector_();
535 this.updateStateFromStatus_();
539 * Handle speaker model saved.
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_();
554 * Remove a hotwording session from the given source.
555 * @param {!hotword.constants.SessionSource} source Source of the hotword
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);
569 * Start a hotwording session.
570 * @param {!hotword.constants.SessionSource} source Source of the hotword
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
577 * @param {hotword.constants.RecognizerStartMode=} opt_mode The mode to
578 * start the recognizer in.
580 startSession: function(source, startedCb, triggerCb,
581 opt_modelSavedCb, opt_mode) {
582 if (this.isTrainingEnabled() && opt_mode) {
583 this.startMode_ = opt_mode;
585 this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
587 hotword.debug('Starting session for source: ' + source);
588 this.removeSession_(source);
589 this.sessions_.push(new Session_(source, triggerCb, startedCb,
591 this.updateStateFromStatus_();
595 * Stops a hotwording session.
596 * @param {!hotword.constants.SessionSource} source Source of the hotword
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
604 if (source == hotword.constants.SessionSource.TRAINING)
605 this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL;
606 this.updateStateFromStatus_();
610 * Handles a chrome.idle.onStateChanged event.
611 * @param {!string} state State, one of "active", "idle", or "locked".
614 handleIdleStateChanged_: function(state) {
615 hotword.debug('Idle state changed: ' + state);
616 var oldLocked = this.isLocked_;
617 if (state == 'locked')
618 this.isLocked_ = true;
620 this.isLocked_ = false;
622 if (oldLocked != this.isLocked_)
623 this.updateStateFromStatus_();
627 * Handles a chrome.runtime.onStartup event.
630 handleStartup_: function() {
631 // Nothing specific needs to be done here. This function exists solely to
632 // be registered on the startup event.
637 StateManager: StateManager