A voice typing daemon for Linux that enables voice typing anywhere the cursor is positioned. Uses Whisper API for speech recognition and provides seamless integration with any application through keyboard input simulation.
The optional GPUI desktop app provides transcript history, playback, editing and revision comparison, recording controls, and statistics. The daemon continues to own recording, transcription and insertion.
# Native development build and launch
direnv exec "$PWD" cargo run --features gui --bin dictator-gui
# Isolated sample history for UI inspection
direnv exec "$PWD" cargo run --features gui --bin dictator-gui -- --demo
# Nix GUI package, separate from the headless daemon package
nix run .#guiThe GUI is a plain windowed app: open it on demand and it exits when its window closes. The daemon keeps running either way. The desktop shell's dictation panel provides the bar indicator, quick recording controls, and the launcher for the GUI. Use matching daemon and GUI builds for microphone priorities and terminal recording updates.
For Home Manager, enable services.dictator.gui.enable = true alongside the
existing daemon configuration to install dictator-gui.
The Settings screen displays daemon configuration read-only. Microphone
priorities live in a separate app-owned preferences file; Home Manager's
config.json is never rewritten by the GUI. Discovery retains disconnected
devices and their order, appends new devices, and resolves an input when a new
recording begins. On PipeWire/PulseAudio, install the PulseAudio client tools
pactl and parec; the Nix packages/module provide them.
History keeps a recording's identity and capture time across retries. Attempts retain their own outcomes and timing. Transcript edits append revisions and restoring an earlier text creates a new revision. Latency statistics cover measured transcription requests; older records without measurements do not get synthetic latency values. See desktop validation for the local acceptance checks and remaining platform limits.
The flake provides CLI and GUI packages for x86_64-linux. Enable flakes and
configure the public kabilan108 cache. On NixOS, add this to your system
configuration and apply your usual rebuild:
nix.settings = {
extra-substituters = [ "https://kabilan108.cachix.org" ];
extra-trusted-public-keys = [
"kabilan108.cachix.org-1:g8OqmhpqE1Bz9DjKTV17uQ3yzsfGcDB5fDgGfVC4t/o="
];
};For other Nix installations, use cachix use kabilan108 with the Cachix client
installed. Then install the packages:
nix profile install github:kabilan108/dictator#default github:kabilan108/dictator#gui
dictator versionFor a specific release, use github:kabilan108/dictator/vVERSION#default and
github:kabilan108/dictator/vVERSION#gui, replacing VERSION with its number.
GUI packages require a release after the historical v2.4.0 tag.
The cache supplies prebuilt packages after that revision's cache workflow
succeeds. A cache miss falls back to a local build.
NixOS/Home Manager users can keep these packages in their configuration
instead of a user profile. Import inputs.dictator.homeManagerModules.default
and configure services.dictator; enable services.dictator.gui.enable for
the desktop app. Retain Dictator's own nixpkgs pin for cache compatibility.
See builds and releases for the workflow and version bumps.
New releases provide an x86_64 Linux CLI tarball with checksums, built outside Nix. See archive installation for supported systems and runtime dependencies. The GUI is distributed through Nix.
Make sure you have the following system dependencies installed:
For X11:
# Ubuntu/Debian
sudo apt install xdotool xclip pulseaudio-utils pkg-config
# Arch Linux
sudo pacman -S xdotool xclip libpulse
# Fedora
sudo dnf install xdotool xclip pulseaudio-utilsFor Wayland:
# Ubuntu/Debian
sudo apt install wl-clipboard wtype pulseaudio-utils pkg-config
# Arch Linux
sudo pacman -S wl-clipboard wtype libpulse
# Fedora
sudo dnf install wl-clipboard wtype pulseaudio-utilsFor a source build, you also need Python 3.11 or newer and a Rust toolchain (cargo, Rust 1.88 or newer). Audio capture uses pactl for discovery and parec for capture. Run PipeWire with pipewire-pulse, or a PulseAudio server. The GUI also needs ffplay from FFmpeg for playback. The Nix development shell supplies the native GUI build dependencies.
-
Clone and build:
git clone https://github.com/kabilan108/dictator.git cd dictator make build -
Install for the current user:
make install
-
Configure API access:
dictator init config_home="${XDG_CONFIG_HOME:-$HOME/.config}" $EDITOR "$config_home/dictator/config.json" # The supplied service reads secrets from this fixed path. install -d -m 700 ~/.config/dictator touch ~/.config/dictator/environment chmod 600 ~/.config/dictator/environment $EDITOR ~/.config/dictator/environment
Add your Whisper API endpoint and an environment-variable reference for the key:
{ "enable_osd": true, "notifications": "errors_only", "api": { "active_provider": "openai", "timeout": 60, "providers": { "openai": { "endpoint": "https://api.openai.com/v1/audio/transcriptions", "key": "${env:OPENAI_API_KEY}", "model": "gpt-4o-transcribe" } } } }Add the referenced variable to
~/.config/dictator/environmentusing systemdEnvironmentFilesyntax (noexport):OPENAI_API_KEY=replace-with-your-key -
Set up the systemd user service:
mkdir -p "$config_home/systemd/user" cp dictator.service "$config_home/systemd/user/dictator.service" systemctl --user daemon-reload
The unit directory above assumes the shell and systemd user manager use the same config root. If they differ, install the unit in the manager's config root instead; the daemon config path can be set independently below.
The supplied unit expects the default Cargo install path,
~/.cargo/bin/dictator. Ifmake installusedCARGO_HOMEorCARGO_INSTALL_ROOT, find the full installed binary path and runsystemctl --user edit dictator.servicebefore enabling the service. Add anExecStart=reset followed by the absolute path:[Service] ExecStart= ExecStart=/absolute/path/to/dictator daemon
dictator inithonorsXDG_CONFIG_HOME. If that variable points somewhere other than~/.configand the systemd user manager does not already have the same value, runsystemctl --user edit dictator.serviceand add it as an absolute path. If you also changedExecStart, put this line under the same[Service]heading:[Service] Environment=XDG_CONFIG_HOME=/absolute/path/to/config-root
The supplied unit limits home-directory writes to Dictator's default config, data, and state directories. If
XDG_CONFIG_HOME,XDG_DATA_HOME, orXDG_STATE_HOMEpoints elsewhere, add every custom app directory to the same drop-in and replace the preparation command with those exact paths:[Service] Environment=XDG_CONFIG_HOME=/custom/config Environment=XDG_DATA_HOME=/custom/data Environment=XDG_STATE_HOME=/custom/state ExecStartPre= ExecStartPre=+/usr/bin/env install -d -m 0700 /custom/config/dictator /custom/data/dictator /custom/state/dictator ReadWritePaths= ReadWritePaths=/custom/config/dictator /custom/data/dictator /custom/state/dictator
Keep the paths absolute. Resetting both list directives prevents the base unit from requiring its default directories after the paths change.
After adding any needed drop-in, enable the service. This attaches it to
graphical-session.targetwithout starting it during a headless login:systemctl --user enable dictator.serviceA desktop session must import its display environment into the systemd user manager before Dictator starts. Many desktop environments already do this and activate
graphical-session.target. Run these commands once from a terminal in the current graphical session to start Dictator now. For a custom compositor, also add them to its graphical-session startup, in this order:systemctl --user import-environment \ DISPLAY XAUTHORITY WAYLAND_DISPLAY XDG_SESSION_TYPE DBUS_SESSION_BUS_ADDRESS if [ -n "${NIRI_SOCKET:-}" ]; then systemctl --user import-environment NIRI_SOCKET fi systemctl --user start dictator.service
Run that startup hook from the graphical session, not from a shell profile or headless boot. It starts Dictator directly and does not manually start
graphical-session.targeton compositors that do not manage that target.The service reads
~/.config/dictator/environmentif it exists. Keep that file private because it contains the API key. If you rundictator daemondirectly instead, exportOPENAI_API_KEYin that shell first.
You can enable Dictator as a Home Manager service via this flake.
Example flake.nix usage:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
dictator.url = "github:kabilan108/dictator";
};
outputs = { self, nixpkgs, home-manager, dictator, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in
{
homeConfigurations."your-user" = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
dictator.homeManagerModules.dictator
{
services.dictator = {
enable = true;
displayServer = "wayland"; # or "x11" / "auto"
logLevel = "INFO";
settings = {
api = {
active_provider = "openai";
timeout = 60;
providers = {
openai = {
endpoint = "https://api.openai.com/v1/audio/transcriptions";
key = "\${env:OPENAI_API_KEY}";
model = "gpt-4o-transcribe";
};
};
};
enable_osd = true;
notifications = "errors_only";
audio = {
max_duration_min = 20;
};
};
};
}
];
};
};
}Notes:
services.dictator.settingsorservices.dictator.configFileis required when enabling the module.displayServercontrols the default runtime dependencies and environment (Wayland vs X11).- If you already manage a config file, set
services.dictator.configFile = /path/to/config.json;. - To use
${env:VAR}in the config, setservices.dictator.environmentFile(supports strings like${XDG_RUNTIME_DIR}/...) orservices.dictator.environment. - The service sandbox derives its writable config, data, and state directories
from Home Manager's XDG paths, or from absolute
XDG_CONFIG_HOME,XDG_DATA_HOME, andXDG_STATE_HOMEvalues inservices.dictator.environment. The effective config root must be withinhome.homeDirectorybecause Home Manager ownsconfig.jsonthere. If anenvironmentFileis intended to change any XDG root at runtime, add directEnvironment,ExecStartPre, andReadWritePathsservice overrides for the resultingdictatordirectories; the unit's direct XDG assignments otherwise take precedence over values from an environment file.
# The daemon runs automatically in the background
# Control voice recording with CLI commands:
# Start recording
dictator start
# Stop recording and transcribe
dictator stop
# Toggle recording on/off
dictator toggle
# Cancel current operation
dictator cancel
# Check daemon status
dictator status
# You can also run the service manually:
dictator daemon
# List recent transcripts
dictator transcripts
# List last 5 transcripts
dictator transcripts -n 5
# Output only text for piping
dictator transcripts -tThe daemon runs in the background and handles all audio recording, transcription, and typing operations.
# Check service status
systemctl --user status dictator.service
# Start/stop/restart the service
systemctl --user start dictator.service
systemctl --user stop dictator.service
systemctl --user restart dictator.service
# View service logs
journalctl --user -u dictator.service -f# Run daemon in foreground
dictator daemon
# Or run in background
nohup dictator daemon > /dev/null 2>&1 &| Command | Description |
|---|---|
start |
Begin voice recording |
stop |
Stop recording and start transcription |
toggle |
Toggle between recording and idle states |
cancel |
Cancel any ongoing operation |
status |
Show daemon status and uptime |
transcripts |
Manage transcript history |
retry [AUDIO_FILE] |
Retry the latest failed transcription or a saved WAV file |
# Retry the most recent failed transcription
dictator retry
# Retry a specific recording, including one saved by an older version
dictator retry ~/.local/share/dictator/recordings/recording.wav
# Save the recovered text to a file
dictator retry > recovered.txtRetry prints the recovered text in the terminal and saves it to transcript history. It preserves the audio file. A successful retry removes that recording from the pending failures, so the next dictator retry selects the next most recent failure.
Only one CLI retry can run at a time. Ctrl-C cancels the request and keeps an existing failure available for another attempt. If the provider succeeds but history cannot be saved, the command still prints the recovered text, reports the storage error on stderr, and exits with a nonzero status.
The command contacts the configured transcription provider directly and works without a running daemon. Run it with the same configuration and API key environment variables as the daemon. Variables loaded only by systemd's EnvironmentFile are not inherited by your terminal.
Automatic failure tracking starts with this version of the daemon. For recordings from earlier versions, pass the WAV path explicitly. Recordings are stored under $XDG_DATA_HOME/dictator/recordings, or ~/.local/share/dictator/recordings by default.
The daemon and CLI communicate through $XDG_RUNTIME_DIR/dictator/dictator.sock. If XDG_RUNTIME_DIR is unavailable, they use /tmp/dictator-$UID/dictator.sock inside a private directory owned by the current user. The Rust CLI does not fall back to the legacy Go socket at /tmp/dictator.sock, because that shared path can be claimed by another user. After upgrading from the Go daemon or an earlier Rust build, restart the daemon before using CLI commands or desktop shortcuts so both processes use the new socket.
A CLI response timeout does not cancel a command already delivered to the daemon. Its outcome is unknown and it may still execute; do not automatically retry a mutating command after a timeout.
Configuration file location: $XDG_CONFIG_HOME/dictator/config.json, defaulting to ~/.config/dictator/config.json when XDG_CONFIG_HOME is unset or empty.
{
"enable_osd": true,
"notifications": "errors_only",
"api": {
"active_provider": "openai",
"timeout": 60,
"providers": {
"openai": {
"endpoint": "https://api.openai.com/v1/audio/transcriptions",
"key": "${env:OPENAI_API_KEY}",
"model": "gpt-4o-transcribe"
}
}
},
"audio": {
"sample_rate": 16000,
"channels": 1,
"bit_depth": 16,
"frames_per_block": 1024,
"max_duration_min": 5
},
"typing": {
"shortcut": "ctrl_shift_v",
"niri_app_shortcuts": {
"com.t3tools.T3Code": "ctrl_v"
}
}
}Audio output is mono 16-bit PCM; channels must be 1 and bit_depth must be 16. Capture is limited to 32 Mi samples, about 34 minutes at 16 kHz. Reaching max_duration_min stops capture and transcribes the recording.
The api.providers.<name>.key field supports ${env:VAR_NAME} substitutions. If the active provider key references missing environment variables, config loading fails. The supplied systemd user service loads variables from ~/.config/dictator/environment when that file exists.
The typing.shortcut field selects the default simulated paste shortcut. It accepts "ctrl_v" or "ctrl_shift_v" and defaults to "ctrl_shift_v". Under Niri, typing.niri_app_shortcuts can override that shortcut for the focused application's Niri app_id; the example uses Ctrl+V for T3 Code, where Ctrl+Shift+V pastes twice. Applications without an override use typing.shortcut, and X11 always uses typing.shortcut. The daemon finds the compositor through NIRI_SOCKET when set, otherwise through $XDG_RUNTIME_DIR/niri.<WAYLAND_DISPLAY>.*.sock, so the override also works from a systemd service that does not import NIRI_SOCKET.
The notifications field controls desktop notifications:
| Value | Behavior |
|---|---|
"all" |
Notify for idle, recording, transcribing, typing, and error states |
"errors_only" |
Notify only when an operation fails |
"off" |
Disable desktop notifications |
When enable_osd is true, the daemon emits visual OSD events on $XDG_RUNTIME_DIR/dictator/osd.sock, falling back to /tmp/dictator-$UID/osd.sock when XDG_RUNTIME_DIR is unavailable.
The OSD socket emits newline-delimited JSON. A new client receives a current state snapshot immediately after connecting.
{"type":"state","value":"recording","recording_duration_ms":0}
{"type":"meter","rms":0.03,"peak":0.2}
{"type":"state","value":"transcribing","recording_duration_ms":4820}
{"type":"state","value":"typing"}
{"type":"state","value":"error","message":"transcription failed"}
{"type":"state","value":"idle"}State events are delivered in order to healthy connected clients. Meter events are best effort: if the OSD falls behind, Dictator keeps only the latest pending meter sample.
A production-ready QuickShell reference client is available in examples/quickshell-osd.
# Enter a shell with the toolchain and native deps (optional, needs nix)
nix develop
# Build release binary (build/dictator)
make build
# Run unit and integration tests
make test
# rustfmt + clippy
make check
# Clean build artifacts
make clean
# Update dependencies
make depsRun dictator --log-level DEBUG daemon for diagnostic output.
- Daemon logs to stderr (capture with
dictator daemon 2> daemon.log) - Application logs stored in
~/.local/state/dictator/app.log - Audio recordings stored in
~/.local/share/dictator/recordings/ - Database stored in
~/.local/share/dictator/app.db - Config stored in
$XDG_CONFIG_HOME/dictator/config.json, with~/.configas the default config root