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