1 // Copyright (c) 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. 4 5 cr.define('hotword', function() { 6 'use strict'; 7 8 /** 9 * Class to manage hotwording state. Starts/stops the hotword detector based 10 * on user settings, session requests, and any other factors that play into 11 * whether or not hotwording should be running. 12 * @constructor 13 * @struct 14 */ 15 function StateManager() { 16 /** 17 * Current state. 18 * @private {hotword.StateManager.State_} 19 */ 20 this.state_ = State_.STOPPED; 21 22 /** 23 * Current hotwording status. 24 * @private {?chrome.hotwordPrivate.StatusDetails} 25 */ 26 this.hotwordStatus_ = null; 27 28 /** 29 * NaCl plugin manager. 30 * @private {?hotword.NaClManager} 31 */ 32 this.pluginManager_ = null; 33 34 /** 35 * Source of the current hotword session. 36 * @private {?hotword.constants.SessionSource} 37 */ 38 this.sessionSource_ = null; 39 40 /** 41 * Callback to run when the hotword detector has successfully started. 42 * @private {!function()} 43 */ 44 this.sessionStartedCb_ = null; 45 46 /** 47 * Hotword trigger audio notification... a.k.a The Chime (tm). 48 * @private {!Audio} 49 */ 50 this.chime_ = document.createElement('audio'); 51 52 // Get the initial status. 53 chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this)); 54 55 // Setup the chime and insert into the page. 56 this.chime_.src = chrome.extension.getURL( 57 hotword.constants.SHARED_MODULE_ROOT + '/audio/chime.wav'); 58 document.body.appendChild(this.chime_); 59 } 60 61 /** 62 * @enum {number} 63 * @private 64 */ 65 StateManager.State_ = { 66 STOPPED: 0, 67 STARTING: 1, 68 RUNNING: 2, 69 ERROR: 3, 70 }; 71 var State_ = StateManager.State_; 72 73 StateManager.prototype = { 74 /** 75 * Request status details update. Intended to be called from the 76 * hotwordPrivate.onEnabledChanged() event. 77 */ 78 updateStatus: function() { 79 chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this)); 80 }, 81 82 /** 83 * Callback for hotwordPrivate.getStatus() function. 84 * @param {chrome.hotwordPrivate.StatusDetails} status Current hotword 85 * status. 86 * @private 87 */ 88 handleStatus_: function(status) { 89 this.hotwordStatus_ = status; 90 this.updateStateFromStatus_(); 91 }, 92 93 /** 94 * Updates state based on the current status. 95 * @private 96 */ 97 updateStateFromStatus_: function() { 98 if (!this.hotwordStatus_) 99 return; 100 101 if (this.hotwordStatus_.enabled) { 102 // Start the detector if there's a session, and shut it down if there 103 // isn't. 104 // TODO(amistry): Support stacking sessions. This can happen when the 105 // user opens google.com or the NTP, then opens the launcher. Opening 106 // google.com will create one session, and opening the launcher will 107 // create the second. Closing the launcher should re-activate the 108 // google.com session. 109 // NOTE(amistry): With always-on, we want a different behaviour with 110 // sessions since the detector should always be running. The exception 111 // being when the user triggers by saying 'Ok Google'. In that case, the 112 // detector stops, so starting/stopping the launcher session should 113 // restart the detector. 114 if (this.sessionSource_) 115 this.startDetector_(); 116 else 117 this.shutdownDetector_(); 118 } else { 119 // Not enabled. Shut down if running. 120 this.shutdownDetector_(); 121 } 122 }, 123 124 /** 125 * Starts the hotword detector. 126 * @private 127 */ 128 startDetector_: function() { 129 // Last attempt to start detector resulted in an error. 130 if (this.state_ == State_.ERROR) { 131 // TODO(amistry): Do some error rate tracking here and disable the 132 // extension if we error too often. 133 } 134 135 if (!this.pluginManager_) { 136 this.state_ = State_.STARTING; 137 this.pluginManager_ = new hotword.NaClManager(); 138 this.pluginManager_.addEventListener(hotword.constants.Event.READY, 139 this.onReady_.bind(this)); 140 this.pluginManager_.addEventListener(hotword.constants.Event.ERROR, 141 this.onError_.bind(this)); 142 this.pluginManager_.addEventListener(hotword.constants.Event.TRIGGER, 143 this.onTrigger_.bind(this)); 144 chrome.runtime.getPlatformInfo(function(platform) { 145 var naclArch = platform.nacl_arch; 146 147 // googDucking set to false so that audio output level from other tabs 148 // is not affected when hotword is enabled. https://crbug.com/357773 149 // content/common/media/media_stream_options.cc 150 var constraints = /** @type {googMediaStreamConstraints} */ 151 ({audio: {optional: [{googDucking: false}]}}); 152 navigator.webkitGetUserMedia( 153 /** @type {MediaStreamConstraints} */ (constraints), 154 function(stream) { 155 if (!this.pluginManager_.initialize(naclArch, stream)) { 156 this.state_ = State_.ERROR; 157 this.shutdownPluginManager_(); 158 } 159 }.bind(this), 160 function(error) { 161 this.state_ = State_.ERROR; 162 this.pluginManager_ = null; 163 }.bind(this)); 164 }.bind(this)); 165 } else if (this.state_ != State_.STARTING) { 166 // Don't try to start a starting detector. 167 this.startRecognizer_(); 168 } 169 }, 170 171 /** 172 * Start the recognizer plugin. Assumes the plugin has been loaded and is 173 * ready to start. 174 * @private 175 */ 176 startRecognizer_: function() { 177 assert(this.pluginManager_); 178 if (this.state_ != State_.RUNNING) { 179 this.state_ = State_.RUNNING; 180 this.pluginManager_.startRecognizer(); 181 } 182 if (this.sessionStartedCb_) { 183 this.sessionStartedCb_(); 184 this.sessionStartedCb_ = null; 185 } 186 }, 187 188 /** 189 * Shuts down and removes the plugin manager, if it exists. 190 * @private 191 */ 192 shutdownPluginManager_: function() { 193 if (this.pluginManager_) { 194 this.pluginManager_.shutdown(); 195 this.pluginManager_ = null; 196 } 197 }, 198 199 /** 200 * Shuts down the hotword detector. 201 * @private 202 */ 203 shutdownDetector_: function() { 204 this.state_ = State_.STOPPED; 205 this.shutdownPluginManager_(); 206 }, 207 208 /** 209 * Handle the hotword plugin being ready to start. 210 * @private 211 */ 212 onReady_: function() { 213 if (this.state_ != State_.STARTING) { 214 // At this point, we should not be in the RUNNING state. Doing so would 215 // imply the hotword detector was started without being ready. 216 assert(this.state_ != State_.RUNNING); 217 this.shutdownPluginManager_(); 218 return; 219 } 220 this.startRecognizer_(); 221 }, 222 223 /** 224 * Handle an error from the hotword plugin. 225 * @private 226 */ 227 onError_: function() { 228 this.state_ = State_.ERROR; 229 this.shutdownPluginManager_(); 230 }, 231 232 /** 233 * Handle hotword triggering. 234 * @private 235 */ 236 onTrigger_: function() { 237 assert(this.pluginManager_); 238 // Detector implicitly stops when the hotword is detected. 239 this.state_ = State_.STOPPED; 240 241 // Play the chime. 242 this.chime_.play(); 243 244 chrome.hotwordPrivate.notifyHotwordRecognition('search', function() {}); 245 246 // Implicitly clear the session. A session needs to be started in order to 247 // restart the detector. 248 this.sessionSource_ = null; 249 this.sessionStartedCb_ = null; 250 }, 251 252 /** 253 * Start a hotwording session. 254 * @param {!hotword.constants.SessionSource} source Source of the hotword 255 * session request. 256 * @param {!function()} startedCb Callback invoked when the session has 257 * been started successfully. 258 */ 259 startSession: function(source, startedCb) { 260 this.sessionSource_ = source; 261 this.sessionStartedCb_ = startedCb; 262 this.updateStateFromStatus_(); 263 }, 264 265 /** 266 * Stops a hotwording session. 267 * @param {!hotword.constants.SessionSource} source Source of the hotword 268 * session request. 269 */ 270 stopSession: function(source) { 271 this.sessionSource_ = null; 272 this.sessionStartedCb_ = null; 273 this.updateStateFromStatus_(); 274 } 275 }; 276 277 return { 278 StateManager: StateManager 279 }; 280 }); 281