Files
Claude a07869f8be Skip virtual keyboard devices (ydotoold) in device detection
find_keyboard() was matching ydotoold virtual device instead of the
real AT Translated Set 2 keyboard because evdev lists devices in
reverse order. Now skips devices with 'virtual' or 'ydotool' in name.

https://claude.ai/code/session_01XKYC1basxdwtHy71tky7xm
2026-03-25 21:42:45 +00:00

134 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""
openwhispr-ptt.py — Push-to-talk wrapper for OpenWhispr
Turns Ctrl+Alt (hold) into Ctrl+` toggle pairs using raw evdev input.
No key repeat issues — reads physical key state directly.
Requires: evdev, xdotool
May need to run as root or add user to 'input' group.
Usage: sudo python3 openwhispr-ptt.py
"""
import subprocess
import signal
import sys
import time
try:
import evdev
from evdev import ecodes
except ImportError:
print("Error: evdev not installed.")
print("Install with: pip3 install evdev --break-system-packages")
sys.exit(1)
WHISPR_HOTKEY = "ctrl+grave"
KEY_LEFTCTRL = ecodes.KEY_LEFTCTRL
KEY_RIGHTCTRL = ecodes.KEY_RIGHTCTRL
KEY_LEFTALT = ecodes.KEY_LEFTALT
KEY_RIGHTALT = ecodes.KEY_RIGHTALT
ctrl_held = False
alt_held = False
is_recording = False
def send_whispr_toggle():
"""Send Ctrl+` to OpenWhispr via xdotool.
Release all held modifiers first, then send the full
keydown ctrl, key grave, keyup ctrl sequence cleanly.
"""
try:
subprocess.run(
["xdotool", "keyup", "ctrl", "alt", "super"],
timeout=2,
capture_output=True,
)
subprocess.run(
["xdotool", "keydown", "ctrl", "key", "grave", "keyup", "ctrl"],
timeout=2,
capture_output=True,
)
except FileNotFoundError:
print("Error: xdotool not installed.")
sys.exit(1)
except subprocess.TimeoutExpired:
pass
def find_keyboard():
"""Find the real physical keyboard device (not virtual devices)."""
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
for dev in devices:
# Skip virtual devices (ydotool, xdotool, etc.)
name_lower = dev.name.lower()
if "virtual" in name_lower or "ydotool" in name_lower:
continue
caps = dev.capabilities(verbose=False)
if ecodes.EV_KEY in caps:
keys = caps[ecodes.EV_KEY]
if ecodes.KEY_A in keys and ecodes.KEY_LEFTCTRL in keys:
return dev
return None
def main():
global ctrl_held, alt_held, is_recording
print("=" * 43)
print(" OpenWhispr Push-to-Talk (evdev)")
print("=" * 43)
print(" Hold: Ctrl + Alt -> start dictation")
print(" Release: Ctrl + Alt -> stop dictation")
print(" Quit: Ctrl + C")
print("=" * 43)
kb = find_keyboard()
if kb is None:
print("")
print("Error: No keyboard found.")
print("Try: sudo python3 ~/openwhispr-ptt.py")
print("Or: sudo usermod -aG input $USER (then log out/in)")
sys.exit(1)
print(" Keyboard: " + kb.name)
print("=" * 43)
print("Listening...\n")
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
for event in kb.read_loop():
if event.type != ecodes.EV_KEY:
continue
# value: 0=release, 1=press, 2=repeat — IGNORE repeats
if event.value == 2:
continue
pressed = (event.value == 1)
if event.code in (KEY_LEFTCTRL, KEY_RIGHTCTRL):
ctrl_held = pressed
elif event.code in (KEY_LEFTALT, KEY_RIGHTALT):
alt_held = pressed
# Start on both pressed
if ctrl_held and alt_held and not is_recording:
is_recording = True
send_whispr_toggle()
print("\033[32m● Recording...\033[0m", flush=True)
# Stop when either released
if is_recording and not (ctrl_held and alt_held):
time.sleep(0.05) # Let key release propagate to X11
send_whispr_toggle()
is_recording = False
print("\033[0m○ Stopped", flush=True)
if __name__ == "__main__":
main()