I got voice typing working on Omarchy with Hyprland, Voxtype, and a pair of wired USB-C EarPods. Two clicks on the inline remote start and stop dictation, three cancel, one still plays and pauses, and saying “submit” presses Return.

Transcription worked out of the box. The button took the afternoon.

The button on the wire

On my machine, plugging in the USB-C EarPods exposes an ALSA audio interface for playback and recording, plus a separate HID input device for the remote on the wire.

In Hyprland (version 0.56.2 here), hyprctl devices lists that remote under keyboards:

Keyboard at 61494e713260:
    apple--inc.-earpods

If your hardware reports a different identifier, hyprctl devices shows the exact string to match.

Running wev or evtest while pressing the center button shows it emits KEY_PLAYPAUSE, which translates to the XKB keysym XF86AudioPlay.

A global binding would redirect XF86AudioPlay from every keyboard. I wanted the EarPods button to handle dictation without sacrificing media control on the wire, while other keyboards keep their instant play and pause.

Hyprland supports per-device keybindings in Lua. In ~/.config/hypr/bindings.lua, I unbound the default global media controls and rebound them conditionally:

-- Unbind global play/pause
hl.unbind("XF86AudioPlay")
hl.unbind("XF86AudioPause")

local handler = os.getenv("HOME") .. "/.local/bin/earpods-voxtype-handler.py"

-- EarPods remote: multi-click dispatcher
o.bind("XF86AudioPlay", "EarPods button (1x: play/pause, 2x: dictation toggle, 3x: cancel)", handler, {
  locked = true,
  device = { inclusive = true, list = { "apple--inc.-earpods" } },
})
o.bind("XF86AudioPause", "EarPods button (1x: play/pause, 2x: dictation toggle, 3x: cancel)", handler, {
  locked = true,
  device = { inclusive = true, list = { "apple--inc.-earpods" } },
})

-- All other keyboards: standard media play/pause
o.bind("XF86AudioPlay", "Play", "omarchy-shell media playPause", {
  locked = true,
  device = { inclusive = false, list = { "apple--inc.-earpods" } },
})
o.bind("XF86AudioPause", "Pause", "omarchy-shell media playPause", {
  locked = true,
  device = { inclusive = false, list = { "apple--inc.-earpods" } },
})

o.bind is Omarchy’s wrapper around Hyprland’s hl.bind. I only saw XF86AudioPlay on this remote; the Pause bindings are belt and braces for devices that emit XF86AudioPause.

Why I switched to a toggle

Voxtype defaults to push-to-talk, and on an inline remote, holding the button down while speaking felt awkward. The microphone is in the remote itself, so pinching it put my fingers on the mic body, and the audio picked up the rustle. Pinching a small capsule in mid-air also pulled the earbud and tired my fingers.

A toggle lets me let go before speaking: double click, drop my hand, talk to a still microphone, double click again.

Preserving play and pause

My thumb expects one click to pause. Overriding a single click for dictation broke that, forcing me back to the keyboard whenever audio was playing.

Moving dictation to a double click preserves play and pause on the wire, and leaves a triple click to discard mistakes. The tradeoff is a 300 ms delay on the EarPods for single clicks, because the dispatcher waits to see if a second click follows. Other keyboards keep instant response.

I wrote a small dispatcher script (~/.local/bin/earpods-voxtype-handler.py) to manage clicks with a sliding window. It inspects Voxtype’s runtime state at /run/user/$UID/voxtype/state (enabled by default via state_file = "auto") so it knows whether the daemon is idle, recording, or transcribing:

  • A single click starts a 300 ms timer. If no second click arrives, it calls omarchy-shell media playPause.
  • A second click within that window slides the 300 ms timer. If no third click arrives, it toggles Voxtype: voxtype record start when idle, or voxtype record stop when recording, streaming, or transcribing.
  • A third click cancels the timer immediately and calls voxtype record cancel, discarding the audio buffer and closing the on-screen display without waiting out the window.

Voxtype includes a record toggle command, but reading the state file directly lets the script handle transcribing as well as recording, ensuring a double click or triple click during inference still resolves cleanly.

The script locks state with fcntl.flock to prevent race conditions when clicks arrive in rapid succession:

#!/usr/bin/env python3
import fcntl
import json
import os
import signal
import subprocess
import sys
import time

TIMEOUT = 0.30       # 300 ms window to detect multi-clicks
COOLDOWN = 0.15      # 150 ms cooldown after an action to ignore accidental bounce

STATE_DIR = f"/run/user/{os.getuid()}/voxtype-earpods"
os.makedirs(STATE_DIR, exist_ok=True)

LOCK_PATH = os.path.join(STATE_DIR, "handler.lock")
STATE_PATH = os.path.join(STATE_DIR, "state.json")
VOX_STATE_FILE = f"/run/user/{os.getuid()}/voxtype/state"


def get_voxtype_state():
    try:
        with open(VOX_STATE_FILE, "r") as f:
            return f.read().strip()
    except FileNotFoundError:
        return "idle"


def load_state():
    try:
        with open(STATE_PATH, "r") as f:
            return json.load(f)
    except (FileNotFoundError, ValueError, json.JSONDecodeError):
        return {"count": 0, "last_click": 0.0, "worker_pid": None, "last_action_time": 0.0}


def save_state(state):
    tmp = f"{STATE_PATH}.tmp.{os.getpid()}"
    with open(tmp, "w") as f:
        json.dump(state, f)
    os.replace(tmp, STATE_PATH)


def is_pid_alive(pid):
    if not pid:
        return False
    try:
        os.kill(pid, 0)
        return True
    except (ProcessLookupError, PermissionError):
        return False


def execute_action(clicks):
    env = os.environ.copy()
    if "OMARCHY_PATH" not in env:
        env["OMARCHY_PATH"] = "/usr/share/omarchy"

    if clicks == 1:
        # Single click: Play / Pause media
        subprocess.run(["omarchy-shell", "media", "playPause"], env=env, check=False)
    elif clicks == 2:
        # Double click: Toggle dictation (start if idle, stop if recording)
        state = get_voxtype_state()
        if state == "idle":
            subprocess.run(["voxtype", "record", "start"], env=env, check=False)
        elif state in ("recording", "transcribing", "streaming"):
            subprocess.run(["voxtype", "record", "stop"], env=env, check=False)
    elif clicks >= 3:
        # Triple click: Cancel dictation (discard audio / OSD)
        subprocess.run(["voxtype", "record", "cancel"], env=env, check=False)


def run_worker():
    while True:
        lock_fd = os.open(LOCK_PATH, os.O_RDWR | os.O_CREAT, 0o600)
        fcntl.flock(lock_fd, fcntl.LOCK_EX)
        state = load_state()

        if state.get("worker_pid") != os.getpid():
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)
            return

        now = time.time()
        last_click = state.get("last_click", now)
        count = state.get("count", 0)
        elapsed = now - last_click

        if elapsed >= TIMEOUT:
            action_count = count
            state["count"] = 0
            state["worker_pid"] = None
            state["last_action_time"] = now
            save_state(state)
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)
            break
        else:
            sleep_time = max(0.01, TIMEOUT - elapsed)
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)

        time.sleep(sleep_time)

    execute_action(action_count)


def main():
    now = time.time()
    lock_fd = os.open(LOCK_PATH, os.O_RDWR | os.O_CREAT, 0o600)
    fcntl.flock(lock_fd, fcntl.LOCK_EX)
    state = load_state()

    # Discard clicks within cooldown period of the previous action
    if now - state.get("last_action_time", 0.0) < COOLDOWN:
        fcntl.flock(lock_fd, fcntl.LOCK_UN)
        os.close(lock_fd)
        sys.exit(0)

    worker_pid = state.get("worker_pid")
    worker_alive = is_pid_alive(worker_pid)

    if not worker_alive:
        # 1st click
        state["count"] = 1
        state["last_click"] = now

        pid = os.fork()
        if pid == 0:
            os.setsid()
            os.close(lock_fd)
            run_worker()
            sys.exit(0)
        else:
            state["worker_pid"] = pid
            save_state(state)
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)
            sys.exit(0)
    else:
        # Subsequent click
        state["count"] += 1
        state["last_click"] = now

        if state["count"] >= 3:
            # Triple click: cancel immediately
            if worker_pid:
                try:
                    os.kill(worker_pid, signal.SIGTERM)
                except ProcessLookupError:
                    pass
            state["count"] = 0
            state["worker_pid"] = None
            state["last_action_time"] = now
            save_state(state)
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)
            execute_action(3)
            sys.exit(0)
        else:
            # 2nd click: worker will wake up and extend timer
            save_state(state)
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)
            sys.exit(0)


if __name__ == "__main__":
    main()

Make it executable with chmod +x ~/.local/bin/earpods-voxtype-handler.py.

Sending the text

Dictation entered the text, but I still needed the keyboard to press Return.

Voxtype includes a feature called smart_auto_submit under the [audio] and [text] sections in ~/.config/voxtype/config.toml:

[audio]
pause_media = true           # pauses music during dictation and resumes after

[text]
smart_auto_submit = true
spoken_punctuation = true    # converts spoken "question mark" to ?, "new line" to Return

pause_media = true keeps music or podcasts from bleeding into the transcription when dictation starts.

I first tried ending a dictation with “send”:

What is pending? Send.

Voxtype typed What is pending? Send. without pressing Return.

I checked line 88 of src/text/mod.rs in Voxtype v1.0.1:

let submit_re = Regex::new(r"(?i)(?:^|\s)submit[.!?,;]*\s*$")

The pattern matches “submit” at the end of the text, with optional trailing punctuation. It does not match “send” or “enter”.

I tried again:

What is pending? Submit.

This typed What is pending? and pressed Return immediately. The prompt submitted.

Try it

To verify the setup:

  1. Restart the daemon after updating config (systemctl --user restart voxtype).
  2. Start music playback and press the EarPods button once. Playback pauses after the 300 ms debounce window.
  3. Double click the button. Playback pauses (via pause_media), the recording overlay opens, and you can speak. Double click again to commit.
  4. Double click to start speaking, then triple click. The overlay closes immediately, discarding the buffer.
  5. In an empty editor or chat prompt, double click, dictate “This is a test. Submit.”, and double click to stop. Voxtype strips “Submit.” and presses Return.

This setup does not control the pointer or switch workspaces by voice. It is an offline dictation loop on Wayland that works from the cable, taking roughly 2.5 seconds from the stopping double click to text appearing on this laptop.

Further reading