Introduction

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.

Download / Installation

  1. Download VoiceAttack
  2. Download the plugin, hojta-2.0.1.zip
  3. "Enable Plugin Support" in VoiceAttack options
  4. Place Hojta folder in Program Files (x86)/VoiceAttack/Apps
  5. Restart VoiceAttack
  6. Import example commands (hojta_examples.vap)

Example code

Plugin structure

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);
    }
}

Focused window

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(' ')
        });
    }
}

Hello world

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'
        });
    }
}

A simple test

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);
    }
}

Change!

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;
    }
}

Even second?

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
        });
    }
}

Buy (pizza | bread | water)

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;
        }
    }
}

Lets take a trip

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'
        });
    }
}

Wildcard / I don't know what you mean?

To enable it go to:

  1. Go to "Edit profile"
  2. Go to "Options and overrides for this profile" (the checkbox to the right of profile name)
  3. Check "Execute a command each time a phrase is unrecognized"
  4. Select "unrecognized"

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 + '?'
        });
    }
}

Development

Tips

Releases

2.0.1

2.0.0

1.1.0

1.0.0

0.1.2

0.1.1

FAQ

Which british voice are you using in the video?

Amy from Ivona