• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 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