Hojta is a VoiceAttack plugin that allows you to easily integrate node.js code into your VoiceAttack flow. Fetch information from the web, implement more extensive logic in an easier and faster way. Hojta is developed and maintained by mape.
Basic overview of a plugin.
// The name of the main plugin file must be ****.hojta.js, it can be positioned
// within nested directories. If you need to use other modules make sure to
// place your ****.hojta.js file and node_modules in a sub directory
// for easy sharing.
// The plugin argument (which is immutable) looks like this:
/*
{
"context": "string",
"textValues": {
"key": null,
"otherKey": "string"
},
"conditions": {
"key": null,
"otherKey": 1 // integer
},
"state": {
"VA_DIR": "C:\Program Files (x86)\VoiceAttack,
"VA_SOUNDS": "C:\Program Files (x86)\VoiceAttack\Sounds",
"VA_APPS": "C:\Program Files (x86)\VoiceAttack\Apps"
},
"extendedValues": null
// Meta contains information like the name/title
// for the currently focused window.
"meta": {
"appName": "myApplication",
"appTitle": "Title - My Application"
}
}
// Helpers contain:
// playSound(path) where path is either relative to the sounds folder in hojta
// or the plugins base path.
*/
module.exports = {
'shouldPluginRun': function(plugin) {
// if the return value is truthy the plugin will run
return plugin.context === 'hello world';
},
'run': function(plugin, respond, store, helpers) {
var textValues = {
'Speak': 'Hello world',
'TextValueName': 'Another string'
};
var conditions = {};
respond(null, textValues, conditions);
}
}
This plugins shows how you can get information about which window is currently focused. You say focused window and it will respond by telling you tje application name and title.
module.exports = {
'shouldPluginRun': function(plugin) {
return plugin.context === 'meta';
},
'run': function(plugin, respond, store, helpers) {
respond(null, {
'Speak': [
'Application name is',
plugin.meta.appName,
'and title is',
plugin.meta.appTitle
].join(' ')
});
}
}
This most basic plugin. You say hello world and it says hello world back.
module.exports = {
'shouldPluginRun': function(plugin) {
return plugin.context === 'hello world';
},
'run': function(plugin, respond, store, helpers) {
respond(null, {
'Speak': 'Hello world'
});
}
}
When you say simple test it plays a sound, waits 1 second and then sends back 2 variables that VoiceAttack reads out. A simple test, Time is h:m:s
module.exports = {
'shouldPluginRun': function(plugin) {
return plugin.context === 'simple test';
},
'run': function(plugin, respond, store, helpers) {
// Play sound from sounds folder
helpers.playSound('visions_CRcv.mp3');
// Wait 1sec and then return data
setTimeout(function() {
respond(null, {
'Speak': 'A simple test',
'Time': new Date().toString().match(/..:..:../)[0]
});
}, 1000);
}
}
When you say change it will alternate its response between On and Off by using the built in plugin store. Saving store.change as a boolean.
module.exports = {
'shouldPluginRun': function(plugin) {
return plugin.context === 'change';
},
'run': function(plugin, respond, store, helpers) {
if (store.change) {
respond(null, {
'Speak': 'On'
});
} else {
respond(null, {
'Speak': 'Off'
});
}
// Toggle change
store.change = !store.change;
}
}
When you say even second the plugin with check what the current second is. It then sends back that value as a textValue along with a codition variable, if the second is even, to VoiceAttack which then responds with either Second is n, not even or Second is n, it is even
Note that this is a bit different from the Change! plugin since here we are passing conditions back to VoiceAttack.
module.exports = {
'shouldPluginRun': function(plugin) {
return plugin.context === 'even second';
},
'run': function(plugin, respond, store, helpers) {
var second = new Date().getSeconds();
var isEven = (second % 2 === 0);
respond(null,
// textValues
{
'Second': second
},
// conditions (values should be integers, not booleans)
{
'IsSecondEven': isEven ? 1 : 0
});
}
}
When you say buy (pizza | bread | water) it will respond differently based on the produce. The food you have selected is passed in as the textValue Food.
As seen in the default case (when saying water) you can easily have a dynamic response based on the input.
module.exports = {
'shouldPluginRun': function(plugin) {
return plugin.context === 'buy';
},
'run': function(plugin, respond, store, helpers) {
switch (plugin.textValues.Food) {
case 'pizza':
respond(null, {
'Speak': 'Pizza would be nice'
});
break;
case 'bread':
respond(null, {
'Speak': 'Garlic bread it is!'
});
break;
default:
respond(null, {
'Speak': 'You wanted ' + plugin.textValues.Food + '?'
});
break;
}
}
}
To test this plugin out this would be the flow of the conversation:
A sound will play, then Chrome will then open up with google maps showing the trip
module.exports = {
'shouldPluginRun': function(plugin) {
return plugin.context === 'travel';
},
'run': function(plugin, respond, store, helpers) {
var exec = require('child_process').exec;
var textValues = plugin.textValues;
if (textValues.Value === 'destinations reset') {
delete store.WaitingForConfirmation;
delete store.WaitingForDestination;
delete store.Destinations;
respond(null, {
'Speak': 'Reset!'
});
return;
}
var matches = new Regex([
'(destinations confirmed'
, '|'
, 'destinations canceled)'
].join(''));
if (store.WaitingForConfirmation && textValues.Value.match(matches)) {
if (textValues.Value === 'destinations confirmed') {
helpers.playSound('visions_CRcv.mp3');
var url = [
'https://www.google.com/maps/dir/'
, store.Destinations.join('/')
, '/'
].join('');
exec('start chrome "'+url+'"', function() {
respond(null, {
'Speak': 'Initiating trip'
});
});
} else {
respond(null, {
'Speak': 'Canceling trip'
});
}
delete store.WaitingForConfirmation;
delete store.WaitingForDestination;
delete store.Destinations;
return;
}
var setDestinations = textValues.Value === 'set destinations';
if (!store.WaitingForDestination && setDestinations) {
store.WaitingForDestination = true;
store.Destinations = [];
respond(null, {
'Speak': 'Add destinations'
});
return;
}
if (store.WaitingForDestination && textValues.Value === 'lets go') {
store.WaitingForConfirmation = true;
respond(null, {
'Speak': [
'We are going to '
, store.Destinations.join(' then ')
, ', is this correct?'
].join('')
});
return;
}
var waiting = store.WaitingForDestination;
if (waiting && !textValues.Value.match(/(set destinations|lets go)/)) {
// Save destination for later use
store.Destinations.push(textValues.Value);
respond(null, {
'Speak': 'Added ' + textValues.Value
});
return;
}
respond(null, {
'Speak': 'Start by saying set destinations'
});
}
}
To enable it go to:
To test it out just try to say any of the car manufacturers and see if it works.
This way of handling unrecognized phrases is very unpredictable naive but it should give a basic idea of how it could work.
The flow would look like this:
module.exports = {
'shouldPluginRun': function(plugin) {
return plugin.context === 'unrecognized';
},
'hasStore': true,
'run': function(plugin, respond, store, helpers) {
if (plugin.textValues.Value.toLowerCase() === 'yes') {
respond(null, {
'Speak': 'Ok! Using ' + store.lastValue
});
return;
}
var levenshtein = require('fast-levenshtein');
var carManufacturers = [
'Aston Martin'
, 'BMW'
, 'Chrysler'
, 'Dodge'
, 'Ferrari'
, 'Jaguar'
, 'Jeep'
, 'Lamborghini'
, 'Land Rover'
, 'Lexus'
, 'Mercedes'
, 'Mitsubishi'
, 'Porsche'
, 'Rolls-Royce'
, 'Tesla'
, 'Toyota'
];
var bestMatch = {
'name': null,
'score': 999
};
carManufacturers.forEach(function(planet) {
var distance = levenshtein.get(plugin.textValues.Value, planet);
if (distance < bestMatch.score) {
bestMatch = {
'name': planet,
'score': distance
}
}
});
store.lastValue = bestMatch.name;
respond(null, {
'Speak': 'Did you mean ' + bestMatch.name + '?'
});
}
}