Voice typing on Wayland with a pair of EarPods
- The button on the wire
- Why I switched to a toggle
- Preserving play and pause
- Sending the text
- Try it
- Further reading
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 startwhen idle, orvoxtype record stopwhen 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:
- Restart the daemon after updating config (
systemctl --user restart voxtype). - Start music playback and press the EarPods button once. Playback pauses after the 300 ms debounce window.
- Double click the button. Playback pauses (via
pause_media), the recording overlay opens, and you can speak. Double click again to commit. - Double click to start speaking, then triple click. The overlay closes immediately, discarding the buffer.
- 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
- Omarchy: the distribution, with its Hyprland Lua wrapper (
o.bind) and desktop defaults - Hyprland per-device binds: scoping keystrokes to a specific keyboard or remote
- Voxtype: the local Whisper push-to-talk daemon, with its configuration reference
- Voxtype
src/text/mod.rs: the regex that parses the “submit” trigger