Push-to-talk dictation for Wayland
  • Go 76.7%
  • Nix 23.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
m4rti e5a646f2ac
All checks were successful
Check / check (push) Successful in 8m33s
Check on every push with Forgejo Actions
The workflow runs `nix flake check` and nothing else, so what CI enforces
is the same thing a developer runs locally, and adding a check means
editing flake.nix rather than the workflow.

The checks are the package build — buildGoModule runs `go test` in its
check phase, so building is testing — plus a formatting check covering
both the Go and the Nix sources, and the Nix linters, mirroring what the
nix config repo already does.

Models moved from a nested `packages.models` attrset to flattened
`packages.model-<name>` entries: flake check requires every packages.*
output to be a derivation.

Also switches the licence metadata to GPL-3.0 to match LICENSE, and adds
an alejandra.toml so the Nix sources use tabs like the other repos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 02:03:42 +02:00
.forgejo/workflows Check on every push with Forgejo Actions 2026-08-25 02:03:42 +02:00
internal Add dictate: local push-to-talk dictation for Wayland 2026-08-25 01:31:13 +02:00
nix Check on every push with Forgejo Actions 2026-08-25 02:03:42 +02:00
.gitignore Add dictate: local push-to-talk dictation for Wayland 2026-08-25 01:31:13 +02:00
alejandra.toml Check on every push with Forgejo Actions 2026-08-25 02:03:42 +02:00
flake.lock Add dictate: local push-to-talk dictation for Wayland 2026-08-25 01:31:13 +02:00
flake.nix Check on every push with Forgejo Actions 2026-08-25 02:03:42 +02:00
go.mod Add dictate: local push-to-talk dictation for Wayland 2026-08-25 01:31:13 +02:00
go.sum Add dictate: local push-to-talk dictation for Wayland 2026-08-25 01:31:13 +02:00
LICENSE Update LICENSE 2026-08-25 01:44:49 +02:00
main.go Add dictate: local push-to-talk dictation for Wayland 2026-08-25 01:31:13 +02:00
README.md Check on every push with Forgejo Actions 2026-08-25 02:03:42 +02:00

dictate

Push-to-talk dictation for Wayland. Press a key, talk, press it again, and what you said is typed into whatever window has focus. Transcription is whisper.cpp running locally — no account, no network, no telemetry.

While it records, a small pill floats above your windows showing a live waveform of what the microphone is picking up and how long you have been talking.

How it works

press ──▶ pw-record streams 16kHz mono PCM ──▶ overlay draws the levels
                                            │
press ──▶ whisper.cpp transcribes ──────────┴──▶ wtype types the result

Three subprocesses and no linked audio stack. dictate itself is the state machine and the indicator.

Wayland compositors hand their key bindings a press but never a release, so hold-to-talk is not something a binding can express. dictate is a toggle instead: the first press opens a recording, the second closes it. The two presses are separate processes — the second one finds the first through a lock file and signals it.

The overlay is a wlr-layer-shell surface rather than an ordinary window, because an ordinary window would take keyboard focus the moment it appeared, and the transcript would then be typed into the indicator instead of into the document you were writing.

Requirements

  • A Wayland compositor with wlr-layer-shell and zwp_virtual_keyboard. niri, sway, Hyprland and river all qualify; GNOME does not.
  • pw-record, whisper-cli, wtype on PATH (the Nix package wires these up).
  • PipeWire. To record through something else, override commands.record with anything that writes 16kHz mono s16le PCM to stdout — ffmpeg -f pulse -i default -ar 16000 -ac 1 -f s16le -, say.

Nothing needs root, a system service, /dev/uinput, or membership in the input group.

Install with Nix

Add the flake and enable the module:

{
  inputs.dictate.url = "git+https://git.cumsek.com/Marti/dictate";

  # ... in your NixOS configuration:
  imports = [inputs.dictate.nixosModules.default];

  programs.dictate = {
    enable = true;
    model = "large-v3-turbo";
    language = "auto";
    # GPU support comes entirely from this package.
    whisperPackage = pkgs.whisper-cpp-vulkan;
  };
}

Then bind it. For niri:

binds {
    Mod+S { spawn "dictate"; }
}

The model is fetched and pinned by Nix, so there is nothing to download by hand on a new machine.

Options

programs.dictate covers model, modelFile, language, threads, gpu, prompt, source, maxSeconds, whisperPackage, and the overlay.* group (enable, anchor, margin, bars, barWidth, barGap, height, gain and colors.*). Anything without an option of its own can go in programs.dictate.settings, which is merged over the rest.

Run dictate without Nix by putting the same keys in $XDG_CONFIG_HOME/dictate/config.json; that file wins over the packaged defaults, key by key.

Usage

dictate            # toggle: start, or stop and transcribe
dictate start      # start, failing if one is already running
dictate stop       # stop and transcribe
dictate cancel     # stop and throw the recording away
dictate status     # exit 0 if recording

Choosing a model

Model Size Notes
tiny 78 MB Fast, and wrong often enough to notice.
base 148 MB Fine for short English.
small 488 MB The sensible floor for multilingual use.
medium 1.5 GB Better, and noticeably slower.
large-v3 3.1 GB Most accurate.
large-v3-turbo 1.6 GB Nearly large-v3, at roughly small's speed.

language = "auto" detects per recording, which is what you want if you switch languages mid-day. Naming the language outright is more accurate on very short clips.

Development

nix develop
go build ./...
go run . start &   # in one shell
go run . stop      # in another

nix flake check runs exactly what CI runs: the package build (which runs go test in its check phase), a formatting check over both the Go and the Nix sources, and the Nix linters.

Licence

GPL-3.0-only. See LICENSE.