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);
106 * Whether this user is locked.
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.
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_
);
150 StateManager
.State_
= {
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
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
,
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
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.
249 updateStateFromStatus_: function() {
250 if (!this.hotwordStatus_
)
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
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_();
278 this.stopDetector_();
281 if (!chrome
.idle
.onStateChanged
.hasListener(
282 this.idleStateChangedListener_
)) {
283 chrome
.idle
.onStateChanged
.addListener(
284 this.idleStateChangedListener_
);
287 // Not enabled. Shut down if running.
288 this.shutdownDetector_();
290 chrome
.idle
.onStateChanged
.removeListener(
291 this.idleStateChangedListener_
);
296 * Starts the hotword detector.
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
328 var constraints
= /** @type {googMediaStreamConstraints} */
329 ({audio
: {optional
: [
330 { googDucking
: false },
331 { googHotword
: this.isAlwaysOnEnabled() }
333 navigator
.webkitGetUserMedia(
334 /** @type {MediaStreamConstraints} */ (constraints
),
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_();
348 if (error
.name
in UmaMediaStreamOpenResults_
) {
349 var metricValue
= UmaMediaStreamOpenResults_
[error
.name
];
352 hotword
.constants
.UmaMediaStreamOpenResult
.UNKNOWN
;
354 hotword
.metrics
.recordEnum(
355 hotword
.constants
.UmaMetrics
.MEDIA_STREAM_RESULT
,
357 hotword
.constants
.UmaMediaStreamOpenResult
.MAX
);
358 this.state_
= State_
.ERROR
;
359 this.pluginManager_
= null;
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
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.
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.
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.
418 shutdownDetector_: function() {
419 this.state_
= State_
.STOPPED
;
420 this.shutdownPluginManager_();
424 * Finalizes the speaker model. Assumes the plugin has been loaded and
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');
434 this.pluginManager_
.finalizeSpeakerModel();
438 * Handle the hotword plugin being ready to start.
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_();
449 this.startRecognizer_();
453 * Handle an error from the hotword plugin.
456 onError_: function() {
457 this.state_
= State_
.ERROR
;
458 this.shutdownPluginManager_();
462 * Handle hotword triggering.
463 * @param {!Event} event Event containing audio log data.
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
;
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.
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
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);
528 * Start a hotwording session.
529 * @param {!hotword.constants.SessionSource} source Source of the hotword
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
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
;
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
,
550 this.updateStateFromStatus_();
554 * Stops a hotwording session.
555 * @param {!hotword.constants.SessionSource} source Source of the hotword
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
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".
573 handleIdleStateChanged_: function(state
) {
574 hotword
.debug('Idle state changed: ' + state
);
575 var oldLocked
= this.isLocked_
;
576 if (state
== 'locked')
577 this.isLocked_
= true;
579 this.isLocked_
= false;
581 if (oldLocked
!= this.isLocked_
)
582 this.updateStateFromStatus_();
587 StateManager
: StateManager