Switch PTT to evdev for reliable key state (no repeat spam)

pynput on X11 sees key repeats as real press/release pairs, causing
rapid-fire toggling. evdev gives event.value=2 for repeats which
we simply ignore. Only real press (1) and release (0) are handled.

Requires sudo or 'input' group membership.

https://claude.ai/code/session_01XKYC1basxdwtHy71tky7xm
This commit is contained in:
Claude
2026-03-25 20:08:57 +00:00
parent 723436dae9
commit 150485ee01
+71 -75
View File
@@ -2,52 +2,39 @@
""" """
openwhispr-ptt.py — Push-to-talk wrapper for OpenWhispr openwhispr-ptt.py — Push-to-talk wrapper for OpenWhispr
Turns Ctrl+Alt (hold) into Ctrl+` toggle pairs: Turns Ctrl+Alt (hold) into Ctrl+` toggle pairs using raw evdev input.
- Press Ctrl+Alt → sends Ctrl+` to START dictation No key repeat issues — reads physical key state directly.
- Release Ctrl+Alt → sends Ctrl+` to STOP dictation
Requires: pynput, xdotool Requires: evdev, xdotool
Usage: python3 openwhispr-ptt.py May need to run as root or add user to 'input' group.
Usage: sudo python3 openwhispr-ptt.py
""" """
import subprocess import subprocess
import threading
import time
import signal import signal
import sys import sys
try: try:
from pynput import keyboard import evdev
from evdev import ecodes
except ImportError: except ImportError:
print("Error: pynput not installed.") print("Error: evdev not installed.")
print("Install with: pip3 install pynput") print("Install with: pip3 install evdev --break-system-packages")
sys.exit(1) sys.exit(1)
# ── Configuration ────────────────────────────────────────────────────────────
# The key combo YOU hold to talk
PTT_MODIFIER = keyboard.Key.ctrl_l
PTT_KEY = keyboard.Key.alt_l
# Also accept right-hand variants
PTT_MODIFIER_ALT = keyboard.Key.ctrl_r
PTT_KEY_ALT = keyboard.Key.alt_r
# The key combo OpenWhispr uses (Ctrl+`)
# We simulate this via xdotool for reliability with Electron apps
WHISPR_HOTKEY = "ctrl+grave" WHISPR_HOTKEY = "ctrl+grave"
# ── State ──────────────────────────────────────────────────────────────────── KEY_LEFTCTRL = ecodes.KEY_LEFTCTRL
KEY_RIGHTCTRL = ecodes.KEY_RIGHTCTRL
KEY_LEFTALT = ecodes.KEY_LEFTALT
KEY_RIGHTALT = ecodes.KEY_RIGHTALT
is_recording = False
press_time = 0
ctrl_held = False ctrl_held = False
super_held = False alt_held = False
lock = threading.Lock() is_recording = False
def send_whispr_toggle(): def send_whispr_toggle():
"""Send Ctrl+` to OpenWhispr via xdotool."""
try: try:
subprocess.run( subprocess.run(
["xdotool", "key", WHISPR_HOTKEY], ["xdotool", "key", WHISPR_HOTKEY],
@@ -55,66 +42,75 @@ def send_whispr_toggle():
capture_output=True, capture_output=True,
) )
except FileNotFoundError: except FileNotFoundError:
print("Error: xdotool not installed. Install with: sudo apt install xdotool") print("Error: xdotool not installed.")
sys.exit(1) sys.exit(1)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
def on_press(key): def find_keyboard():
global is_recording, press_time, ctrl_held, super_held """Find the keyboard device."""
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
with lock: for dev in devices:
# Track modifier state — ignore key repeat (already True) caps = dev.capabilities(verbose=False)
if key in (PTT_MODIFIER, PTT_MODIFIER_ALT): if ecodes.EV_KEY in caps:
ctrl_held = True keys = caps[ecodes.EV_KEY]
elif key in (PTT_KEY, PTT_KEY_ALT): if ecodes.KEY_A in keys and ecodes.KEY_LEFTCTRL in keys:
super_held = True return dev
return None
# Start recording when both held and not already recording
if ctrl_held and super_held and not is_recording:
press_time = time.time()
is_recording = True
send_whispr_toggle() # START
print("\033[32m● Recording...\033[0m", flush=True)
def on_release(key):
global is_recording, press_time, ctrl_held, super_held
with lock:
released_ptt = False
if key in (PTT_MODIFIER, PTT_MODIFIER_ALT):
ctrl_held = False
released_ptt = True
elif key in (PTT_KEY, PTT_KEY_ALT):
super_held = False
released_ptt = True
# Stop recording when either key is truly released
if is_recording and released_ptt:
elapsed_ms = (time.time() - press_time) * 1000
send_whispr_toggle() # STOP
is_recording = False
print(f"\033[0m○ Stopped ({int(elapsed_ms)}ms)", flush=True)
def main(): def main():
print("═══════════════════════════════════════════") global ctrl_held, alt_held, is_recording
print(" OpenWhispr Push-to-Talk")
print("═══════════════════════════════════════════") print("=" * 43)
print(" Hold: Ctrl + Alt → start dictation") print(" OpenWhispr Push-to-Talk (evdev)")
print(" Release: Ctrl + Alt → stop dictation") print("=" * 43)
print(" Hold: Ctrl + Alt -> start dictation")
print(" Release: Ctrl + Alt -> stop dictation")
print(" Quit: Ctrl + C") print(" Quit: Ctrl + C")
print("═══════════════════════════════════════════") 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") print("Listening...\n")
# Handle Ctrl+C gracefully
signal.signal(signal.SIGINT, lambda *_: sys.exit(0)) signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
with keyboard.Listener(on_press=on_press, on_release=on_release) as listener: for event in kb.read_loop():
listener.join() 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):
send_whispr_toggle()
is_recording = False
print("\033[0m○ Stopped", flush=True)
if __name__ == "__main__": if __name__ == "__main__":