Files
dikte/hotkey.py
T
yusufipk efa8687b23 Voice dictation for KDE Wayland: record, transcribe, clean up, paste
Ctrl+Space starts and stops a recording. The audio goes to OpenAI for
transcription, a model on OpenRouter strips the fillers and restores
punctuation, and the result is copied and pasted into the focused window.

Only the Python standard library and PyQt6 — HTTP, multipart uploads and
WAV writing are all hand-rolled.

- pw-record captures raw 16 kHz mono PCM with a live level meter
- the corner indicator is drawn through XWayland, since a Wayland client
  cannot position its own window
- silence is caught before it costs an API call, relative to each
  recording's own noise floor, plus a filter for the stock phrases models
  invent when handed silence
- audio and video files can be transcribed too, optionally with [mm:ss]
  timestamps, chunked through ffmpeg for long inputs
- global shortcut installs as a KDE custom shortcut, with an evdev
  listener as a fallback until the session is restarted
- Turkish and English interface, following the system locale by default
2026-07-25 19:24:46 +07:00

253 lines
8.5 KiB
Python

"""Global shortcut: KDE custom-shortcut installation plus a built-in evdev listener."""
import glob
import os
import pathlib
import re
import select
import struct
import subprocess
import threading
from PyQt6.QtCore import QObject, pyqtSignal
from i18n import t
DESKTOP_ID = "dikte-toggle.desktop"
DESKTOP_FILE = pathlib.Path.home() / ".local/share/applications" / DESKTOP_ID
SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc"
# --- evdev key codes (linux/input-event-codes.h) --------------------------
EV_KEY = 0x01
KEYS = {
"space": 57, "tab": 15, "enter": 28, "return": 28, "esc": 1, "escape": 1,
"backspace": 14, "insert": 110, "delete": 111, "home": 102, "end": 107,
"pgup": 104, "pgdown": 109, "up": 103, "down": 108, "left": 105, "right": 106,
"1": 2, "2": 3, "3": 4, "4": 5, "5": 6, "6": 7, "7": 8, "8": 9, "9": 10, "0": 11,
"q": 16, "w": 17, "e": 18, "r": 19, "t": 20, "y": 21, "u": 22, "i": 23, "o": 24,
"p": 25, "a": 30, "s": 31, "d": 32, "f": 33, "g": 34, "h": 35, "j": 36, "k": 37,
"l": 38, "z": 44, "x": 45, "c": 46, "v": 47, "b": 48, "n": 49, "m": 50,
"f1": 59, "f2": 60, "f3": 61, "f4": 62, "f5": 63, "f6": 64, "f7": 65, "f8": 66,
"f9": 67, "f10": 68, "f11": 87, "f12": 88,
}
MODS = {
"ctrl": (29, 97), "control": (29, 97),
"shift": (42, 54),
"alt": (56, 100),
"meta": (125, 126), "super": (125, 126),
}
ALL_MOD_CODES = {code for pair in MODS.values() for code in pair}
def parse_shortcut(text):
"""'Ctrl+Space' -> ({'ctrl'}, 57), or (None, None) when unparsable."""
parts = [p.strip().lower() for p in str(text).split("+") if p.strip()]
if not parts:
return None, None
mods, key = set(), None
for part in parts:
if part in MODS:
mods.add("ctrl" if part == "control" else "super" if part == "meta" else part)
else:
key = KEYS.get(part)
if key is None:
return None, None
if key is None:
return None, None
return mods, key
# --- built-in listener ----------------------------------------------------
class EvdevHotkey(QObject):
"""Catches a global shortcut by reading /dev/input directly.
It does not swallow the key — the focused application sees the combination
too. This is the fallback that works before the KDE shortcut goes live.
"""
triggered = pyqtSignal()
failed = pyqtSignal(str)
EVENT_FMT = "llHHi"
EVENT_SIZE = struct.calcsize(EVENT_FMT)
def __init__(self, parent=None):
super().__init__(parent)
self._thread = None
self._stop = threading.Event()
self._mods = set()
self._key = None
@property
def running(self):
return self._thread is not None and self._thread.is_alive()
def start(self, shortcut):
self.stop()
mods, key = parse_shortcut(shortcut)
if key is None:
self.failed.emit(t("Could not parse the shortcut: {shortcut}", shortcut=shortcut))
return False
devices = self._open_devices()
if not devices:
self.failed.emit(t(
"Cannot read /dev/input. Your user needs to be in the 'input' group:\n"
" sudo usermod -aG input $USER (then log out and back in)"
))
return False
self._mods, self._key = mods, key
self._stop.clear()
self._thread = threading.Thread(target=self._loop, args=(devices,), daemon=True)
self._thread.start()
return True
def stop(self):
self._stop.set()
if self._thread:
self._thread.join(timeout=1.5)
self._thread = None
def _open_devices(self):
fds = []
for path in sorted(glob.glob("/dev/input/event*")):
try:
fds.append(os.open(path, os.O_RDONLY | os.O_NONBLOCK))
except OSError:
continue
return fds
def _loop(self, fds):
held = set()
try:
while not self._stop.is_set():
ready, _, _ = select.select(fds, [], [], 0.4)
for fd in ready:
try:
data = os.read(fd, self.EVENT_SIZE * 64)
except (BlockingIOError, OSError):
continue
for offset in range(0, len(data) - self.EVENT_SIZE + 1, self.EVENT_SIZE):
_s, _us, etype, code, value = struct.unpack(
self.EVENT_FMT, data[offset:offset + self.EVENT_SIZE]
)
if etype != EV_KEY:
continue
if code in ALL_MOD_CODES:
held.add(code) if value else held.discard(code)
elif code == self._key and value == 1:
if self._mods_match(held):
self.triggered.emit()
finally:
for fd in fds:
try:
os.close(fd)
except OSError:
pass
def _mods_match(self, held):
for name, codes in MODS.items():
if name in ("control", "super"):
continue
canonical = "ctrl" if name == "control" else name
pressed = any(code in held for code in codes)
if pressed != (canonical in self._mods):
return False
return True
# --- KDE custom shortcut --------------------------------------------------
def install_kde_shortcut(shortcut, exec_command, name="Dikte: start/stop recording"):
"""Write the desktop file and the kglobalshortcutsrc entry.
KWin only reads that file at startup, so the entry goes live after the next
login. Returns (True, message) or (False, error).
"""
try:
DESKTOP_FILE.parent.mkdir(parents=True, exist_ok=True)
DESKTOP_FILE.write_text(
"[Desktop Entry]\n"
f"Exec={exec_command}\n"
f"Name={name}\n"
"NoDisplay=true\n"
"StartupNotify=false\n"
"Type=Application\n"
"X-KDE-GlobalAccel-CommandShortcut=true\n",
encoding="utf-8",
)
except OSError as exc:
return False, t("Could not write the desktop file: {error}", error=exc)
try:
subprocess.run(
["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc",
"--group", "services", "--group", DESKTOP_ID,
"--key", "_launch", shortcut],
capture_output=True, text=True, timeout=10, check=True,
)
except (subprocess.SubprocessError, OSError) as exc:
return False, t("Could not write kglobalshortcutsrc: {error}", error=exc)
return True, t(
"Shortcut saved: {shortcut}\nKWin only reads this file at startup, so it "
"will not fire until you log out and back in. To use it right away, turn "
"on the built-in listener.",
shortcut=shortcut,
)
def remove_kde_shortcut():
try:
DESKTOP_FILE.unlink(missing_ok=True)
except OSError:
pass
try:
subprocess.run(
["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc",
"--group", "services", "--group", DESKTOP_ID, "--key", "_launch", "--delete"],
capture_output=True, timeout=10,
)
except (subprocess.SubprocessError, OSError):
pass
def kde_shortcut_status():
"""The registered shortcut, or None."""
if not DESKTOP_FILE.exists():
return None
try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8")
except OSError:
return None
match = re.search(
r"\[services\]\[" + re.escape(DESKTOP_ID) + r"\]\n_launch=([^\n]*)", text
)
if not match:
return None
value = match.group(1).split("\t")[0].strip()
return value or None
def conflicting_shortcuts(shortcut):
"""Names of other KDE entries bound to the same combination."""
try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8")
except OSError:
return []
hits, section = [], ""
for line in text.splitlines():
if line.startswith("["):
section = line.strip("[]").replace("][", " / ")
continue
if "=" not in line or DESKTOP_ID in section:
continue
key, _, value = line.partition("=")
if shortcut.lower() in value.lower().split(","):
hits.append(f"{section}{key}")
elif any(shortcut.lower() == part.strip().lower()
for part in re.split(r"[,\t]", value)):
hits.append(f"{section}{key}")
return hits