mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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
253 lines
8.5 KiB
Python
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
|