-
Notifications
You must be signed in to change notification settings - Fork 4
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
Changes from 1 commit
7db037b
5f7b3eb
c203b60
321506a
97031fe
f8ea88a
5380126
b22d518
6e19da5
2a778da
2c33f80
b54052b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
app/js/third_party | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -67,6 +67,10 @@ | |
"worker": true | ||
}, | ||
|
||
"globals": { | ||
"JsSpeechRecognizer" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You don't need to make it a global if it's only loaded via es6 modules in a file. |
||
}, | ||
|
||
"plugins": [ | ||
"react" | ||
], | ||
|
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,12 @@ | ||
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 { | ||
|
@@ -23,6 +25,20 @@ export default class MainController extends BaseController { | |
'reminders': remindersController, | ||
}; | ||
|
||
const speechController = new SpeechController(); | ||
speechController.on( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would move this block earlier in the constructor so that the |
||
'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)); | ||
} | ||
|
||
|
@@ -34,6 +50,10 @@ export default class MainController extends BaseController { | |
}); | ||
} | ||
|
||
this[p.speechController].start().then(() => { | ||
console.log('Speech controller started'); | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,222 @@ | ||
'use strict'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import EventDispatcher from './common/event-dispatcher'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's start all the js files in the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: no need for a line break here. |
||
constructor() { | ||
super(EVENT_INTERFACE); | ||
|
||
const recognizer = new WakeWordRecognizer(); | ||
|
||
recognizer.setOnKeywordSpottedCallback( | ||
this._handleKeywordSpotted.bind(this)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Let's keep the alignment simple and only indent such lines with 2 spaces. |
||
|
||
this[p.wakewordRecognizer] = recognizer; | ||
this[p.wakewordModelUrl] = '/data/wakeword_model.json'; | ||
} | ||
|
||
_initializeSpeechRecognition() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
return fetch('/data/wakeword_model.json') | ||
.then((response) => response.json()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is
third_party
folder still needed?