--clearmodifiers releases ALL modifiers including Ctrl, so xdotool sends bare grave (types 'v'). Instead, explicitly release only Alt before sending Ctrl+grave so OpenWhispr receives the correct combo. https://claude.ai/code/session_01XKYC1basxdwtHy71tky7xm
131 lines
3.5 KiB
Python
131 lines
3.5 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.
|
|
|
|
We first release Alt (which we're physically holding), send Ctrl+grave,
|
|
then re-press Alt. This way OpenWhispr sees clean Ctrl+` without
|
|
the Alt modifier corrupting it.
|
|
"""
|
|
try:
|
|
subprocess.run(
|
|
["xdotool", "keyup", "alt"],
|
|
timeout=2,
|
|
capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["xdotool", "key", WHISPR_HOTKEY],
|
|
timeout=2,
|
|
capture_output=True,
|
|
)
|
|
except FileNotFoundError:
|
|
print("Error: xdotool not installed.")
|
|
sys.exit(1)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
|
|
def find_keyboard():
|
|
"""Find the keyboard device."""
|
|
devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
|
|
for dev in devices:
|
|
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()
|