- Take a script & give it parameters.
- Write sentences and entity lists for the script.
- Execute the script using text or speech.
yo is:
- Nix: compile-time command-language compiler & verifier
- Rust: deterministic run-time interpreter, matcher & dispatcher
- Lightweight: Legacy CLI requires only
pkgs.bash+pkgs.jq&pkgs.coreutils!
Nix Build Time
▶ declarative command definitions
▶ grammar expansion
▶ parameter/entity expansion
▶ pattern & phrase generation
▶ command index generation
▶ conflict detection
▶ test generation
▶ compile-time verification
Rust Run Time
At runtime,yo donormalizes the input and concurrently evaluates exact and fuzzy matches against the pre-compiled command index.
Exact matching always takes precedence; the fuzzy matcher waits for the exact result before it is allowed to dispatch a command.
Once a match is selected, parameters are extracted and the correspondingyoscript is dispatched with those arguments.
🦆🏠 HOME via 🐍 via 🦀 v1.98.0 took 1m31s
03:36:35 ❯ yo do "seetlt ao tiimezrr fobor twoz hourazs ninre minuotes twentyonz<e secondips"
┌─(yo-timer-en)
│🦆 qwack!? seetlt ao tiimezrr fobor twoz hourazs ninre minuotes twentyonz<e secondips
└─⮞ --hours 2
└─⮞ --minutes 9
└─⮞ --seconds 21
└─⏰ do took 52.417625ms
{
"status": "ok",
"timer_id": 1
}
yo is also a full-stack voice assistant that's:
- Very Fast - Pre-compiled indexes, priority-ordered exact matching, parallel fuzzy evaluation & Rust performance.
- Simple - Everything neatly packaged and runs on a single port.
- Safe - Rule based, user defines the rules. Strong validation included.
- Configurable - Optimize fuzzy threshold per script, makes it very flexible.
- Offline - No internet required after setup.
- Embeddable - ESP32 based clients in Rust using the yo-esp library.
- Ready - Voice commands are exact/fuzzy tested for conflicts before service even starts.
- Deployable - 100% reproducible using the NixOS flake.
yo is NOT:
- ❌ An LLM with shell access!
❄️ Using flakes
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
yo.url = "github:quackhack-mcblindy/yo";
}; imports = [ yo.nixosModules.yo ];Note: the module also requires
selfandinputsas module arguments. Pass them to yournixosSystemviaspecialArgs:
nixosSystem {
specialArgs = { inherit self inputs; };
...
}Example of a minimal server + client service configuration (view 2. Usage for a full configuration).
services.yo-rs = {
server = {
enable = true;
language = "swedish"; # "english" by default
whisper = "base";
shellTranslate = true;
};
client.enable = true;
};$ sudo nixos-rebuild switch --flake /path/to/flake ...Done!
Now you can speak your wake word (default: "yo bitch")
& ask what time it is.
or if you prefer CLI:
❄️ DOTFILES on main [$!+]
✦ 07:17:33 ❯ yo do "what time is it"
┌─(yo-time)
│🦆 qwack!? what time is it
└─🦆 says ⮞ no parameters yo
└─⏰ do took 183.835µs
07:17Approx: ~0.184 ms
But if you don't like Rust, or have a basic setup you can use Bash (Which only depends on pkgs.jq and pkgs.coreutils) instead by setting:
yo.legacy = true;yo uses ONNX Runtime for text-to-speech inference and wake-word detection.
GGML-based bin models from the Whisper family is used for speech-to-text.
Note: models are automatically fetched by Nix. Just remember to set a language in the server configuration.
Service configuration
Full usage example:
services.yo-rs = {
port = 12345;
openFirewall = true;
server = {
enable = true;
shellTranslate = true; # true = executes yo scripts
language = "swedish"; # controls the transcription language + TTS model (default = `"english"`)
whisper = "medium"; # (tiny, base, small, medium, large)
threshold = 0.8; # wake word detection trigger threshold
beamSize = 0; # 0 = greedy (often faster)
temperature = 0.2; # can reduce hallucinations
threads = 4; # CPU threads, increase for speed
ttsSpeed = 1.3; # text-to-speech length-scale
# additional optional settings:
# host = "0.0.0.0:12345";
# wakeWordPath = "/path/to/custom/model.onnx";
# awakeSound = "/path/to/custom/awake.wav";
# doneSound = "/path/to/custom/done.wav";
# failSound = "/path/to/custom/fail.wav";
# debug = true;
# logFile = "/path/to/custom/log/path/yo-rs-server.log";
};
# Microphone client (streams audio - RMS based VAD)
client = {
enable = true; # enables microphone streaming to server
uri = "192.168.1.111"; # server ip (leave unchanged when server & client on same host)
room = "livingroom";
silenceThreshold = 0.03; # when to concider audio silent
silenceTimeout = 1.5; # wait x seconds after silent before sending
maxDuration = 5.0; # max recording in seconds before sending
# awakeSound = "/path/to/custom/awake.wav";
# doneSound = "/path/to/custom/done.wav";
# failSound = "/path/to/custom/fail.wav";
# awakeCmd = "notify-send 'Wake word detected'";
# doneCmd = "mpg123 /path/to/success.mp3";
# failCmd = "mpg123 /path/to/success.mp3";
# debug = true;
# logFile = "/path/to/custom/log/path/yo-rs-client.log";
};
}; Yo configuration
Most of the options are baked into the service or scripts, but there are a couple of options:
yo = {
fuzzy = {
enable = false; # disable fuzzy globally
threshold = 0.9; # global runtime threshold
conflict = {
detection = false; # detect near-duplicate sentences at build-time
threshold = 80; # build-time Jaccard percentage (0–100)
};
};
legacy = false; # set to true to run using Bash
splitWords = [ "also" ]; # used for chaining commands
sorryPhrases = [ # TTS when failing
"Buddy, you are speaking Japanese, I dont understand anything."
"I'm sorry, I did not understand that"
"Sorry, can you repeat that"
"I did not quite catch that"
"Excuse me?!"
];
};Script configuration
You can see real yo.scripts in the ./examples directory.
yo.scripts.timer = {
description = "What this script does";
category = "Home Automation"; # used for grouping in `yo --help`
aliases = [ "tim" ]; # alternative CLI names
autoStart = false; # start at boot? (requires defaults for required parameters)
runEvery = "55"; # run periodically (systemd timer)
runAt = [ "08:00" "20:00" ]; # run at specific times daily
logLevel = "INFO"; # DEBUG, INFO, WARNING, ERROR, CRITICAL
helpFooter = "Additional help text"; # additional data shown in the scripts `--help` command
parameters = [
{ name = "minutes"; type = "int"; description = "Minutes to set the timer on"; default = 0; }
{ name = "seconds"; type = "int"; description = "Seconds to set the timer on"; default = 0; }
{ name = "hours"; type = "int"; description = "Hours to set the timer on"; default = 0; }
{ name = "list"; type = "bool"; description = "Lists active timers"; default = false; }
{ name = "sound"; type = "path"; description = "Soundfile to be played on finished timer"; default = "/path/to/finished.wav"; }
];
# binary = /path/to/executable;
# write your own code or just link to a executable binary
code = ''
SOUNDFILE="$sound"
HOURS="$hours"
MINUTES="$minutes"
SECONDS="$seconds"
LOGFILE_DIR="/tmp/yo-timers"
mkdir -p "$LOGFILE_DIR"
if [ "$list" = "true" ]; then
timers=()
counter=1
if ls "$LOGFILE_DIR"/*.pid >/dev/null 2>&1; then
for pidfile in "$LOGFILE_DIR"/*.pid; do
pid=$(basename "$pidfile" .pid)
if ps -p "$pid" >/dev/null 2>&1; then
end_time=$(awk '{print $2}' "$pidfile")
remaining=$((end_time - $(date +%s)))
if [ $remaining -gt 0 ]; then
hours_left=$((remaining / 3600))
minutes_left=$(((remaining % 3600) / 60))
seconds_left=$((remaining % 60))
finish_time=$(date -d @$end_time +'%H:%M:%S')
timers+=("{\"id\":$pid,\"counter\":$counter,\"target\":\"$finish_time\",\"hours_left\":$hours_left,\"minutes_left\":$minutes_left,\"seconds_left\":$seconds_left}")
counter=$((counter + 1))
fi
else
rm -f "$pidfile"
fi
done
fi
if [ ''${#timers[@]} -eq 0 ]; then
echo '{"timers":[]}'
else
printf '{"timers":[%s]}\n' "$(IFS=,; echo "''${timers[*]}")"
fi
exit 0
fi
TIMER_TOTAL=$((HOURS * 3600 + MINUTES * 60 + SECONDS))
DURATION=$TIMER_TOTAL
TIMER_MINUTES=$((DURATION / 60))
start_time=$(date +%s)
end_time=$((start_time + DURATION))
(
while [ $(date +%s) -lt $end_time ]; do
now=$(date +%s)
remaining=$((end_time - now))
echo -ne "Time remaining: ''${remaining}s\r"
sleep 1
done
echo -e "\n\e[1;5;31m[TIMER FINISHED]\e[0m"
rm -f "$LOGFILE_DIR/$$.pid"
if [ -f "$SOUNDFILE" ]; then
for i in {1..10}; do
aplay "$SOUNDFILE" >/dev/null 2>&1
done
sleep 15
for i in {1..8}; do
aplay "$SOUNDFILE" >/dev/null 2>&1
done
else
echo "Sound file not found: $SOUNDFILE"
fi
) > /tmp/yo-timer.log 2>&1 &
pid=$!
echo "$pid $end_time" > "$LOGFILE_DIR/$pid.pid"
disown "$pid"
'';
};Voice configuration
This is how you define sentences with words.
(these|are|alternative|words)
[these|are|optional|words]
{parameters}A good rule of thumb is to place the sentences with the most parameters at the top and work your way down.
You can see real yo.scripts in the ./examples directory.
yo.scripts.timer = {
voice = {
enabled = true; # wether to enable matching this script
speak = false; # wether to automatically send script output to text-to-speech
priority = 5; # (1-5) 5 is priorities last
fuzzy.enabled = true; # script specific
fuzzy.threshold = 0.8; # script specific
sentences = [
"(skapa|ställ|sätt|starta) [en] (time|timer|timern) [på] {hours} (timme|timmar) {minutes} (minut|minuter) {seconds} (sekund|sekunder)"
"(skapa|ställ|sätt|starta) [en] (time|timer|timern) [på] {minutes} (minut|minuter) [och] {seconds} (sekund|sekunder)"
"(skapa|ställ|sätt|starta) [en] (time|timer|timern) [på] {minutes} (minut|minuter)"
"(skapa|ställ|sätt|starta) [en] (time|timer|timern) [på] {seconds} sekunder"
"hur {list} är det kvar på (time|timer|timern)"
"tid {list} på (time|timer|timern)"
"när {list} (time|timer|timern)"
];
lists = {
list.values = [
{ "in" = "länge|kvar"; out = "true"; }
];
seconds.values = builtins.concatLists (builtins.genList (
i: let n = i + 1; in [
{ "in" = toString n; out = toString n; }
{ "in" = swedishNumber n; out = toString n; }
]
) 60);
minutes.values = builtins.concatLists (builtins.genList (
i: let n = i + 1; in [
{ "in" = toString n; out = toString n; }
{ "in" = swedishNumber n; out = toString n; }
]
) 60);
hours.values = builtins.concatLists (builtins.genList (
i: let n = i + 1; in [
{ "in" = toString n; out = toString n; }
{ "in" = swedishNumber n; out = toString n; }
]
) 24);
};
};
};Compile-time sentence conflict evaluation
All checks are pure Nix assertions – if a conflict is found, nixos-rebuild fails and a helpful error message is shown.
200| if failedAssertions != [ ] then
201| throw "\nFailed assertions:\n${concatStringsSep "\n" (map (x: "- ${x}") failedAssertions)}"
| ^
202| else
error:
Failed assertions:
- Sentence conflicts detected in voice definition:
🦆 says ⮞ CONFLICT!
Pattern "set a reminder for {time} to {task}"
In scripts: calendar_event, reminderr
🦆 says ⮞ fix da conflicts before rebuildin' yo!Fuzzy Conflict Detection
Jaccard Similarity compares two sentences by looking at their sets of words (tokens). It’s calculated as:
similarity = (number of shared words) / (total unique words in both sentences)
The result is a percentage.
Example:
"play music in the living room"vs"play radio in the living room"
Shared words:play, in, the, living, room(5)
Unique words total:play, music, in, the, living, room, radio(7)
Similarity = 5 / 7 ≈ 71%
{
yo.fuzzy.conflict.detection = true; # default: false
yo.fuzzy.conflict.threshold = 70; # default: 80
}Will enable the fuzzy conflict detection and configure its sensitivity value.
It's disabled by default as it (of course) increases the duration of user rebuilds, but is quite useful for testing.
200| if failedAssertions != [ ] then
201| throw "\nFailed assertions:\n${concatStringsSep "\n" (map (x: "- ${x}") failedAssertions)}"
| ^
202| else
error:
Failed assertions:
- 🦆 duck say ⮞ fuck ❌ Fuzzy index may contain near-duplicate sentences (token similarity > 70%):
'play music in the living room' (play_music) vs 'play radio in the living room' (play_radio) [71%]Commandline
Natural Language Command
Commands are executed from the terminal:
$ yo do "turn off all lights in the livingroom"Adding \? at the end of your command will run it with DEBUG logging.
Exact matches are blazing fast.
Fuzzy matching has great coverage/accuracy.
The duck will let you know if you just had an exact match by saying:
🦆 qwack!
if it was an fuzzy match:
🦆 qwack?!
Run yo --help to see all your defined yo scripts as a table. (Can also be viewed at /etc/yo/table.md)
yo <script> --help shows all of the yo scripts information as well as the number of generated patterns and phrases for the defined voice commands.
The ratio could be a good way to measure potential combinatorial explosion as you can see below.
❄️ DOTFILES on main [!]
00:41:44 ❯ yo tv -h
🦆🏠 HOME via 🐍 via 🦀 v1.98.0
00:49:35 ❯ yo tv -h
Android TV Controller. Fuzzy search all media types and creates playlist and
serves over webserver for casting.
Usage: yo tv [OPTIONS]
## Parameters
--typ
Specify the type of command or the media type to search for.
Supported commands are:
on, off, up, down, call, favorites, star.
Media Types:
tv, movie, livetv, podcast, music, song, musicvideo, jukebox (random music),
othervideo, youtube.
Device Naviagation:
nav_up, nav_down, nav_left, nav_right, nav_select, nav_menu, nav_back
(optional) (default: tv)
(allowed: on, off, up, down, next, prev, call, favourites, star, tv, movie,
livetv, podcast, music, song, musicvideo, jukebox, othervideo, youtube,
nav_up, nav_down, nav_left, nav_right, nav_select, nav_menu, nav_back,
channel_up, channel_down)
--search
Media to search
(optional)
--room
Room name of device to play on
(optional)
--season
Specific season to play
(optional)
--shuffle
Shuffle Toggle, true or false
(optional) (default: true)
## Voice
Patterns: 245
Phrases: 1,608
Ratio: 6 ❄️ DOTFILES on main [!]
00:41:52 ❯ yo timer -h
...
## Voice
Patterns: 921
Phrases: 262,010,985
Ratio: 284,485 Note: for legacy that timer script becomes 6.66 MB, while for Rust version well below 100 kilobytes.
Hint: Tte timer script is a perfect example of when priority
5should be defined.
To see a total of generated patterns/phrases and averge benchmarking statistics: yo do --help.
Text-To-Speech
If you would run for example:
yo say "this is my spoken text"From your yo server, you would hear this is my spoken text on all connected client's speakers.
If that does not work as epected users may hardcode a list of client IP's:
services.yo-rs = {
client = {
ttsClients = [ "192.168.1.123" "192.168.1.124" ];
};
};
}Runtime Sentence Testing
Runtime sentence testing is also supported.
yo testsIf you have many voice commands it's advised to provide a maximum variants parameter for the tests:
yo tests --max-variants 50
# or if you want to test single script
yo tests --script <name> --max-variants 200 Learn how to write your own voice commands in the examples/
For inspiration, view my /bin - which has voice scripts that range from easy to advanced usage.
Read about the fuzzy matching logic in the docs/
Read more about the feature set in the docs/
🦯🦆 says ⮞ Hi! I'm QuackHack-McBlindy!
Like my work?
Buy me a coffee, or become a sponsor.
Thanks for supporting open source/hungry developers♥️ 🦆!
MIT
Contributions are welcomed.
