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 5cr.define('hotword', function() { 6'use strict'; 7 8/** 9 * Class used to manage the state of the NaCl recognizer plugin. Handles all 10 * control of the NaCl plugin, including creation, start, stop, trigger, and 11 * shutdown. 12 * 13 * @constructor 14 * @extends {cr.EventTarget} 15 */ 16function NaClManager() { 17 /** 18 * Current state of this manager. 19 * @private {hotword.NaClManager.ManagerState_} 20 */ 21 this.recognizerState_ = ManagerState_.UNINITIALIZED; 22 23 /** 24 * The window.timeout ID associated with a pending message. 25 * @private {?number} 26 */ 27 this.naclTimeoutId_ = null; 28 29 /** 30 * The expected message that will cancel the current timeout. 31 * @private {?string} 32 */ 33 this.expectingMessage_ = null; 34 35 /** 36 * Whether the plugin will be started as soon as it stops. 37 * @private {boolean} 38 */ 39 this.restartOnStop_ = false; 40 41 /** 42 * NaCl plugin element on extension background page. 43 * @private {?Nacl} 44 */ 45 this.plugin_ = null; 46 47 /** 48 * URL containing hotword-model data file. 49 * @private {string} 50 */ 51 this.modelUrl_ = ''; 52 53 /** 54 * Media stream containing an audio input track. 55 * @private {?MediaStream} 56 */ 57 this.stream_ = null; 58}; 59 60/** 61 * States this manager can be in. Since messages to/from the plugin are 62 * asynchronous (and potentially queued), it's not possible to know what state 63 * the plugin is in. However, track a state machine for NaClManager based on 64 * what messages are sent/received. 65 * @enum {number} 66 * @private 67 */ 68NaClManager.ManagerState_ = { 69 UNINITIALIZED: 0, 70 LOADING: 1, 71 STOPPING: 2, 72 STOPPED: 3, 73 STARTING: 4, 74 RUNNING: 5, 75 ERROR: 6, 76 SHUTDOWN: 7, 77}; 78var ManagerState_ = NaClManager.ManagerState_; 79var Error_ = hotword.constants.Error; 80 81NaClManager.prototype.__proto__ = cr.EventTarget.prototype; 82 83/** 84 * Called when an error occurs. Dispatches an event. 85 * @param {!hotword.constants.Error} error 86 * @private 87 */ 88NaClManager.prototype.handleError_ = function(error) { 89 event = new Event(hotword.constants.Event.ERROR); 90 event.data = error; 91 this.dispatchEvent(event); 92}; 93 94/** 95 * @return {boolean} True if the recognizer is in a running state. 96 */ 97NaClManager.prototype.isRunning = function() { 98 return this.recognizerState_ == ManagerState_.RUNNING; 99}; 100 101/** 102 * Set a timeout. Only allow one timeout to exist at any given time. 103 * @param {!function()} func 104 * @param {number} timeout 105 * @private 106 */ 107NaClManager.prototype.setTimeout_ = function(func, timeout) { 108 assert(!this.naclTimeoutId_); 109 this.naclTimeoutId_ = window.setTimeout( 110 function() { 111 this.naclTimeoutId_ = null; 112 func(); 113 }.bind(this), timeout); 114}; 115 116/** 117 * Clears the current timeout. 118 * @private 119 */ 120NaClManager.prototype.clearTimeout_ = function() { 121 window.clearTimeout(this.naclTimeoutId_); 122 this.naclTimeoutId_ = null; 123}; 124 125/** 126 * Starts a stopped or stopping hotword recognizer (NaCl plugin). 127 */ 128NaClManager.prototype.startRecognizer = function() { 129 if (this.recognizerState_ == ManagerState_.STOPPED) { 130 this.recognizerState_ = ManagerState_.STARTING; 131 this.sendDataToPlugin_(hotword.constants.NaClPlugin.RESTART); 132 this.waitForMessage_(hotword.constants.TimeoutMs.LONG, 133 hotword.constants.NaClPlugin.READY_FOR_AUDIO); 134 } else if (this.recognizerState_ == ManagerState_.STOPPING) { 135 // Wait until the plugin is stopped before trying to start it. 136 this.restartOnStop_ = true; 137 } else { 138 throw 'Attempting to start NaCl recogniser not in STOPPED or STOPPING ' + 139 'state'; 140 } 141}; 142 143/** 144 * Stops the hotword recognizer. 145 */ 146NaClManager.prototype.stopRecognizer = function() { 147 this.sendDataToPlugin_(hotword.constants.NaClPlugin.STOP); 148 this.recognizerState_ = ManagerState_.STOPPING; 149 this.waitForMessage_(hotword.constants.TimeoutMs.NORMAL, 150 hotword.constants.NaClPlugin.STOPPED); 151}; 152 153/** 154 * Checks whether the file at the given path exists. 155 * @param {!string} path Path to a file. Can be any valid URL. 156 * @return {boolean} True if the patch exists. 157 * @private 158 */ 159NaClManager.prototype.fileExists_ = function(path) { 160 var xhr = new XMLHttpRequest(); 161 xhr.open('HEAD', path, false); 162 try { 163 xhr.send(); 164 } catch (err) { 165 return false; 166 } 167 if (xhr.readyState != xhr.DONE || xhr.status != 200) { 168 return false; 169 } 170 return true; 171}; 172 173/** 174 * Creates and returns a list of possible languages to check for hotword 175 * support. 176 * @return {!Array.<string>} Array of languages. 177 * @private 178 */ 179NaClManager.prototype.getPossibleLanguages_ = function() { 180 // Create array used to search first for language-country, if not found then 181 // search for language, if not found then no language (empty string). 182 // For example, search for 'en-us', then 'en', then ''. 183 var langs = new Array(); 184 if (hotword.constants.UI_LANGUAGE) { 185 // Chrome webstore doesn't support uppercase path: crbug.com/353407 186 var language = hotword.constants.UI_LANGUAGE.toLowerCase(); 187 langs.push(language); // Example: 'en-us'. 188 // Remove country to add just the language to array. 189 var hyphen = language.lastIndexOf('-'); 190 if (hyphen >= 0) { 191 langs.push(language.substr(0, hyphen)); // Example: 'en'. 192 } 193 } 194 langs.push(''); 195 return langs; 196}; 197 198/** 199 * Creates a NaCl plugin object and attaches it to the page. 200 * @param {!string} src Location of the plugin. 201 * @return {!Nacl} NaCl plugin DOM object. 202 * @private 203 */ 204NaClManager.prototype.createPlugin_ = function(src) { 205 var plugin = document.createElement('embed'); 206 plugin.src = src; 207 plugin.type = 'application/x-nacl'; 208 document.body.appendChild(plugin); 209 return plugin; 210}; 211 212/** 213 * Initializes the NaCl manager. 214 * @param {!string} naclArch Either 'arm', 'x86-32' or 'x86-64'. 215 * @param {!MediaStream} stream A stream containing an audio source track. 216 * @return {boolean} True if the successful. 217 */ 218NaClManager.prototype.initialize = function(naclArch, stream) { 219 assert(this.recognizerState_ == ManagerState_.UNINITIALIZED); 220 var langs = this.getPossibleLanguages_(); 221 var i, j; 222 // For country-lang variations. For example, when combined with path it will 223 // attempt to find: '/x86-32_en-gb/', else '/x86-32_en/', else '/x86-32_/'. 224 for (i = 0; i < langs.length; i++) { 225 var folder = hotword.constants.SHARED_MODULE_ROOT + '/_platform_specific/' + 226 naclArch + '_' + langs[i] + '/'; 227 var dataSrc = folder + hotword.constants.File.RECOGNIZER_CONFIG; 228 var pluginSrc = hotword.constants.SHARED_MODULE_ROOT + '/hotword_' + 229 langs[i] + '.nmf'; 230 var dataExists = this.fileExists_(dataSrc) && this.fileExists_(pluginSrc); 231 if (!dataExists) { 232 continue; 233 } 234 235 var plugin = this.createPlugin_(pluginSrc); 236 this.plugin_ = /** @type {Nacl} */ (plugin); 237 if (!this.plugin_ || !this.plugin_.postMessage) { 238 document.body.removeChild(this.plugin_); 239 this.recognizerState_ = ManagerState_.ERROR; 240 return false; 241 } 242 this.modelUrl_ = chrome.extension.getURL(dataSrc); 243 this.stream_ = stream; 244 this.recognizerState_ = ManagerState_.LOADING; 245 246 plugin.addEventListener('message', 247 this.handlePluginMessage_.bind(this), 248 false); 249 250 plugin.addEventListener('crash', 251 this.handleError_.bind(this, Error_.NACL_CRASH), 252 false); 253 return true; 254 } 255 this.recognizerState_ = ManagerState_.ERROR; 256 return false; 257}; 258 259/** 260 * Shuts down the NaCl plugin and frees all resources. 261 */ 262NaClManager.prototype.shutdown = function() { 263 if (this.plugin_ != null) { 264 document.body.removeChild(this.plugin_); 265 this.plugin_ = null; 266 } 267 this.clearTimeout_(); 268 this.recognizerState_ = ManagerState_.SHUTDOWN; 269 this.stream_ = null; 270}; 271 272/** 273 * Sends data to the NaCl plugin. 274 * @param {!string} data Command to be sent to NaCl plugin. 275 * @private 276 */ 277NaClManager.prototype.sendDataToPlugin_ = function(data) { 278 assert(this.recognizerState_ != ManagerState_.UNINITIALIZED); 279 this.plugin_.postMessage(data); 280}; 281 282/** 283 * Waits, with a timeout, for a message to be received from the plugin. If the 284 * message is not seen within the timeout, dispatch an 'error' event and go into 285 * the ERROR state. 286 * @param {number} timeout Timeout, in milliseconds, to wait for the message. 287 * @param {!string} message Message to wait for. 288 * @private 289 */ 290NaClManager.prototype.waitForMessage_ = function(timeout, message) { 291 assert(this.expectingMessage_ == null, 292 'Already waiting for message ' + this.expectingMessage_); 293 this.setTimeout_( 294 function() { 295 this.recognizerState_ = ManagerState_.ERROR; 296 this.handleError_(Error_.TIMEOUT); 297 }.bind(this), timeout); 298 this.expectingMessage_ = message; 299}; 300 301/** 302 * Called when a message is received from the plugin. If we're waiting for that 303 * message, cancel the pending timeout. 304 * @param {string} message Message received. 305 * @private 306 */ 307NaClManager.prototype.receivedMessage_ = function(message) { 308 if (message == this.expectingMessage_) { 309 this.clearTimeout_(); 310 this.expectingMessage_ = null; 311 } 312}; 313 314/** 315 * Handle a REQUEST_MODEL message from the plugin. 316 * The plugin sends this message immediately after starting. 317 * @private 318 */ 319NaClManager.prototype.handleRequestModel_ = function() { 320 if (this.recognizerState_ != ManagerState_.LOADING) { 321 return; 322 } 323 this.sendDataToPlugin_( 324 hotword.constants.NaClPlugin.MODEL_PREFIX + this.modelUrl_); 325 this.waitForMessage_(hotword.constants.TimeoutMs.LONG, 326 hotword.constants.NaClPlugin.MODEL_LOADED); 327}; 328 329/** 330 * Handle a MODEL_LOADED message from the plugin. 331 * The plugin sends this message after successfully loading the language model. 332 * @private 333 */ 334NaClManager.prototype.handleModelLoaded_ = function() { 335 if (this.recognizerState_ != ManagerState_.LOADING) { 336 return; 337 } 338 this.sendDataToPlugin_(this.stream_.getAudioTracks()[0]); 339 this.waitForMessage_(hotword.constants.TimeoutMs.LONG, 340 hotword.constants.NaClPlugin.MS_CONFIGURED); 341}; 342 343/** 344 * Handle a MS_CONFIGURED message from the plugin. 345 * The plugin sends this message after successfully configuring the audio input 346 * stream. 347 * @private 348 */ 349NaClManager.prototype.handleMsConfigured_ = function() { 350 if (this.recognizerState_ != ManagerState_.LOADING) { 351 return; 352 } 353 this.recognizerState_ = ManagerState_.STOPPED; 354 this.dispatchEvent(new Event(hotword.constants.Event.READY)); 355}; 356 357/** 358 * Handle a READY_FOR_AUDIO message from the plugin. 359 * The plugin sends this message after the recognizer is started and 360 * successfully receives and processes audio data. 361 * @private 362 */ 363NaClManager.prototype.handleReadyForAudio_ = function() { 364 if (this.recognizerState_ != ManagerState_.STARTING) { 365 return; 366 } 367 this.recognizerState_ = ManagerState_.RUNNING; 368}; 369 370/** 371 * Handle a HOTWORD_DETECTED message from the plugin. 372 * The plugin sends this message after detecting the hotword. 373 * @private 374 */ 375NaClManager.prototype.handleHotwordDetected_ = function() { 376 if (this.recognizerState_ != ManagerState_.RUNNING) { 377 return; 378 } 379 // We'll receive a STOPPED message very soon. 380 this.recognizerState_ = ManagerState_.STOPPING; 381 this.waitForMessage_(hotword.constants.TimeoutMs.NORMAL, 382 hotword.constants.NaClPlugin.STOPPED); 383 this.dispatchEvent(new Event(hotword.constants.Event.TRIGGER)); 384}; 385 386/** 387 * Handle a STOPPED message from the plugin. 388 * This plugin sends this message after stopping the recognizer. This can happen 389 * either in response to a stop request, or after the hotword is detected. 390 * @private 391 */ 392NaClManager.prototype.handleStopped_ = function() { 393 this.recognizerState_ = ManagerState_.STOPPED; 394 if (this.restartOnStop_) { 395 this.restartOnStop_ = false; 396 this.startRecognizer(); 397 } 398}; 399 400/** 401 * Handles a message from the NaCl plugin. 402 * @param {!Event} msg Message from NaCl plugin. 403 * @private 404 */ 405NaClManager.prototype.handlePluginMessage_ = function(msg) { 406 if (msg['data']) { 407 this.receivedMessage_(msg['data']); 408 switch (msg['data']) { 409 case hotword.constants.NaClPlugin.REQUEST_MODEL: 410 this.handleRequestModel_(); 411 break; 412 case hotword.constants.NaClPlugin.MODEL_LOADED: 413 this.handleModelLoaded_(); 414 break; 415 case hotword.constants.NaClPlugin.MS_CONFIGURED: 416 this.handleMsConfigured_(); 417 break; 418 case hotword.constants.NaClPlugin.READY_FOR_AUDIO: 419 this.handleReadyForAudio_(); 420 break; 421 case hotword.constants.NaClPlugin.HOTWORD_DETECTED: 422 this.handleHotwordDetected_(); 423 break; 424 case hotword.constants.NaClPlugin.STOPPED: 425 this.handleStopped_(); 426 break; 427 } 428 } 429}; 430 431return { 432 NaClManager: NaClManager 433}; 434 435}); 436