Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Evented speech controller #4

Merged
merged 12 commits into from
Jun 27, 2016
1 change: 1 addition & 0 deletions app/data/wakeword_model.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions app/js/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ if (!('fetch' in self)) {
polyfills.push('polyfills/fetch');
}

polyfills.push('polyfills/webrtc-adapter');

const polyfillsPromise = polyfills.length ?
require(polyfills) : Promise.resolve();
polyfillsPromise.then(() => require(['js/app.js']));
20 changes: 20 additions & 0 deletions app/js/controllers/main.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import BaseController from './base';
import UsersController from './users';
import RemindersController from './reminders';
import SpeechController from '../lib/speech-controller';

const p = Object.freeze({
controllers: Symbol('controllers'),
onHashChanged: Symbol('onHashChanged'),
speechController: Symbol('speechController'),
});

export default class MainController extends BaseController {
constructor() {
super();

const speechController = new SpeechController();
const mountNode = document.querySelector('.app-view-container');
const options = { mountNode };

Expand All @@ -23,6 +26,19 @@ export default class MainController extends BaseController {
'reminders': remindersController,
};

speechController.on(
Copy link
Contributor Author

@samgiles samgiles Jun 24, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the minute, I'm not certain of the best way to send these events to the correct UI (currently active controller) to display something - is there something simple we can do?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move this block earlier in the constructor so that the speechController instance can be passed as an option to the controllers (just like mountNode). Then it'll be up to the controllers to do whatever they want with it.

'wakelistenstart', () => console.log('wakelistenstart'));

speechController.on('wakelistenstop', () => console.log('wakelistenstop'));
speechController.on('wakeheard', () => console.log('wakeheard'));
speechController.on(
'speechrecognitionstart', () => console.log('speechrecognitionstart'));

speechController.on(
'speechrecognitionstop', () => console.log('speechrecognitionstop'));

this[p.speechController] = speechController;

window.addEventListener('hashchange', this[p.onHashChanged].bind(this));
}

Expand All @@ -34,6 +50,10 @@ export default class MainController extends BaseController {
});
}

this[p.speechController].start().then(() => {
console.log('Speech controller started');
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe in the future we want to wait until the speech controller has started before starting the app to prevent nasty race conditions.
I don't think that's likely to happen though. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is likely to happen as it is, as the only interface we expose is events right now - I think it's OK for now.


location.hash = '';
setTimeout(() => {
//location.hash = 'users/login';
Expand Down
222 changes: 222 additions & 0 deletions app/js/lib/common/event-dispatcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
'use strict';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file comes with unit test. We can defer adding them to later but in the meantime, can you open an issue so that we don't forget about it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue #6


/*
* This file provides an helper to add custom events to any object.
*
* In order to use this functionality with any object consumer should extend
* target object class with EventDispatcher:
*
* class Obj extends EventDispatcher {}
* const obj = new Obj();
*
* A list of events can be optionally provided and it is recommended to do so.
* If a list is provided then only the events present in the list will be
* allowed. Using events not present in the list will cause other functions to
* throw an error:
*
* class Obj extends EventDispatcher {
* constructor() {
* super(['somethinghappened', 'somethingelsehappened']);
* }
* }
* const obj = new Obj();
*
* The object will have five new methods: 'on', 'once', 'off', 'offAll' and
* 'emit'. Use 'on' to register a new event-handler:
*
* obj.on("somethinghappened", function onSomethingHappened() { ... });
*
* If the same event-handler is added multiple times then only one will be
* registered, e.g.:
*
* function onSomethingHappened() { ... }
* obj.on("somethinghappened", onSomethingHappened);
* obj.on("somethinghappened", onSomethingHappened); // Does nothing
*
* Use 'off' to remove a registered listener:
*
* obj.off("somethinghappened", onSomethingHappened);
*
* Use 'once' to register a one-time event-handler: it will be automatically
* unregistered after being called.
*
* obj.once("somethinghappened", function onSomethingHappened() { ... });
*
* And use 'offAll' to remove all registered event listeners for the specified
* event:
*
* obj.offAll("somethinghappened");
*
* When used without parameters 'offAll' removes all registered event handlers,
* this can be useful when writing unit-tests.
*
* Finally use 'emit' to send an event to the registered handlers:
*
* obj.emit("somethinghappened");
*
* An optional parameter can be passed to 'emit' to be passed to the registered
* handlers:
*
* obj.emit("somethinghappened", 123);
*/

const assertValidEventName = function(eventName) {
if (!eventName || typeof eventName !== 'string') {
throw new Error('Event name should be a valid non-empty string!');
}
};

const assertValidHandler = function(handler) {
if (typeof handler !== 'function') {
throw new Error('Handler should be a function!');
}
};

const assertAllowedEventName = function(allowedEvents, eventName) {
if (allowedEvents && allowedEvents.indexOf(eventName) < 0) {
throw new Error(`Event "${eventName}" is not allowed!`);
}
};

const p = Object.freeze({
allowedEvents: Symbol('allowedEvents'),
listeners: Symbol('listeners'),
});

export default class EventDispatcher {
constructor(allowedEvents) {
if (typeof allowedEvents !== 'undefined' && !Array.isArray(allowedEvents)) {
throw new Error('Allowed events should be a valid array of strings!');
}

this[p.listeners] = new Map();
this[p.allowedEvents] = allowedEvents;
}

/**
* Registers listener function to be executed once event occurs.
*
* @param {string} eventName Name of the event to listen for.
* @param {function} handler Handler to be executed once event occurs.
*/
on(eventName, handler) {
assertValidEventName(eventName);
assertAllowedEventName(this[p.allowedEvents], eventName);
assertValidHandler(handler);

let handlers = this[p.listeners].get(eventName);
if (!handlers) {
handlers = new Set();
this[p.listeners].set(eventName, handlers);
}

// Set.add ignores handler if it has been already registered.
handlers.add(handler);
}

/**
* Registers listener function to be executed only first time when event
* occurs.
*
* @param {string} eventName Name of the event to listen for.
* @param {function} handler Handler to be executed once event occurs.
*/
once(eventName, handler) {
assertValidHandler(handler);

const once = (parameters) => {
this.off(eventName, once);

handler.call(this, parameters);
};

this.on(eventName, once);
}

/**
* Removes registered listener for the specified event.
*
* @param {string} eventName Name of the event to remove listener for.
* @param {function} handler Handler to remove, so it won't be executed
* next time event occurs.
*/
off(eventName, handler) {
assertValidEventName(eventName);
assertAllowedEventName(this[p.allowedEvents], eventName);
assertValidHandler(handler);

const handlers = this[p.listeners].get(eventName);
if (!handlers) {
return;
}

handlers.delete(handler);

if (!handlers.size) {
this[p.listeners].delete(eventName);
}
}

/**
* Removes all registered listeners for the specified event.
*
* @param {string=} eventName Name of the event to remove all listeners for.
*/
offAll(eventName) {
if (typeof eventName === 'undefined') {
this[p.listeners].clear();
return;
}

assertValidEventName(eventName);
assertAllowedEventName(this[p.allowedEvents], eventName);

const handlers = this[p.listeners].get(eventName);
if (!handlers) {
return;
}

handlers.clear();

this[p.listeners].delete(eventName);
}

/**
* Emits specified event so that all registered handlers will be called
* with the specified parameters.
*
* @param {string} eventName Name of the event to call handlers for.
* @param {Object=} parameters Optional parameters that will be passed to
* every registered handler.
*/
emit(eventName, parameters) {
assertValidEventName(eventName);
assertAllowedEventName(this[p.allowedEvents], eventName);

const handlers = this[p.listeners].get(eventName);
if (!handlers) {
return;
}

handlers.forEach((handler) => {
try {
handler.call(this, parameters);
} catch (error) {
console.error(error);
}
});
}

/**
* Checks if there are any listeners that listen for the specified event.
*
* @param {string} eventName Name of the event to check listeners for.
* @returns {boolean}
*/
hasListeners(eventName) {
assertValidEventName(eventName);
assertAllowedEventName(this[p.allowedEvents], eventName);

return this[p.listeners].has(eventName);
}
}
97 changes: 97 additions & 0 deletions app/js/lib/speech-controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import EventDispatcher from './common/event-dispatcher';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's start all the js files in the lib/ folder with 'use strict';.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this is really necessary? Point 2: http://www.ecma-international.org/ecma-262/6.0/#sec-strict-mode-code

import WakeWordRecognizer from './wakeword/recogniser.js';

const p = Object.freeze({
wakewordRecognizer: Symbol('wakewordRecognizer'),
wakewordModelUrl: Symbol('wakewordModelUrl'),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: no need to align the values here.

});

const EVENT_INTERFACE = [
// Emit when the wakeword is being listened for
'wakelistenstart',

// Emit when the wakeword is no longer being listened for
'wakelistenstop',

// Emit when the wakeword is heard
'wakeheard',

// Emit when the speech recognition engine starts listening
// (And _could_ be sending speech over the network)
'speechrecognitionstart',

// Emit when the speech recognition engine returns a recognised phrase
'speechrecognitionstop',
];

export default class SpeechController extends EventDispatcher {
constructor() {
super(EVENT_INTERFACE);

const recognizer = new WakeWordRecognizer();

recognizer.setOnKeywordSpottedCallback(
this._handleKeywordSpotted.bind(this));

this[p.wakewordRecognizer] = recognizer;
this[p.wakewordModelUrl] = '/data/wakeword_model.json';
}

_initializeSpeechRecognition() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all private methods, let's stick to the "symbols properties" pattern (See const p = Object.freeze({... above),

return fetch('/data/wakeword_model.json')
.then((response) => response.json())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: indentations in this file.

.then((model) => {
this[p.wakewordRecognizer].loadModel(model);
});
}

start() {
return this._initializeSpeechRecognition()
.then(this._startListeningForWakeword.bind(this));
}

_startListeningForWakeword() {
this.emit(EVENT_INTERFACE[0]);
return this[p.wakewordRecognizer].startListening();
}

_stopListeningForWakeword() {
this.emit(EVENT_INTERFACE[1]);
return this[p.wakewordRecognizer].stopListening();
}

_handleKeywordSpotted() {
this.emit(EVENT_INTERFACE[2]);

return this._stopListeningForWakeword()
.then(this._startSpeechRecognition.bind(this))
.then(this._handleSpeechRecognitionEnd.bind(this))
.then(this._startListeningForWakeword.bind(this));
}

_startSpeechRecognition() {
this.emit(EVENT_INTERFACE[3]);
// Mock recognised phrase for now
return Promise.resolve({
phrase: 'remind me to pick up laundry on my way home this evening',
});
}

_handleSpeechRecognitionEnd(result) {
this.emit(EVENT_INTERFACE[4]);

// Parse intent
return this._parseIntent(result.phrase)
.then(this._actOnIntent.bind(this));
}

_parseIntent() {
// Parse - return 'result' (TBD) async
return Promise.resolve();
}

_actOnIntent() {
// Act - return 'result' (TBD) async
return Promise.resolve();
}
}
Loading