diff --git a/README.md b/README.md index de518bd..03e8dd0 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,15 @@ systemctl --user enable --now ydotool # needed for auto-paste dikte # the settings window opens on first run ``` -`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the -KDE shortcut. +On Ubuntu/GNOME X11, recording uses PulseAudio and clipboard/paste use the X11 +tools instead: + +```sh +sudo apt install pulseaudio-utils xclip xdotool ffmpeg +``` + +`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The +settings window installs a GNOME or KDE global shortcut. Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text runs on either one (`gpt-4o-transcribe` by default), cleanup always on diff --git a/README.tr.md b/README.tr.md index 3e8a877..c620ebf 100644 --- a/README.tr.md +++ b/README.tr.md @@ -29,8 +29,15 @@ systemctl --user enable --now ydotool # otomatik yapıştırma için dikte # ilk açılışta ayarlar penceresi gelir ``` -`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik -başlatmayı ve KDE kısayolunu kurar. +Ubuntu/GNOME X11 için kayıt PulseAudio üzerinden, pano ve yapıştırma ise X11 +araçlarıyla çalışır: + +```sh +sudo apt install pulseaudio-utils xclip xdotool ffmpeg +``` + +`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik +başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar. Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`), diff --git a/audio.py b/audio.py index a9f47ed..0200058 100644 --- a/audio.py +++ b/audio.py @@ -31,7 +31,7 @@ MIN_FRAMES = int(RATE * 0.25) class Recorder(QObject): - """Runs pw-record as a child process and reads raw PCM from its stdout.""" + """Runs the available sound-server recorder and reads raw PCM from stdout.""" level = pyqtSignal(float) # 0.0 - 1.0, for the waveform stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS @@ -53,21 +53,13 @@ class Recorder(QObject): def start(self, target="", max_seconds=300): if self.active: return - if not shutil.which("pw-record"): - self.failed.emit(t("pw-record not found. Is pipewire-audio installed?")) + cmd = recording_command(target) + if not cmd: + self.failed.emit(t( + "No audio recorder found. Install pulseaudio-utils or pipewire-audio." + )) return - cmd = [ - "pw-record", - "--raw", - f"--rate={RATE}", - f"--channels={CHANNELS}", - "--format=s16", - ] - if target: - cmd.append(f"--target={target}") - cmd.append("-") - try: self._proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0 @@ -84,7 +76,8 @@ class Recorder(QObject): self._thread.start() def _pump(self): - stdout = self._proc.stdout + proc = self._proc + stdout = proc.stdout try: while True: chunk = stdout.read(CHUNK_BYTES) @@ -101,6 +94,15 @@ class Recorder(QObject): break except (OSError, ValueError): pass + if not self._cancelled and not self._buffer and proc.poll() is not None: + try: + detail = proc.stderr.read().decode("utf-8", "replace").strip() + except (AttributeError, OSError): + detail = "" + self.failed.emit(t( + "Audio recorder stopped before receiving sound: {error}", + error=detail or f"exit code {proc.returncode}", + )) def _terminate(self): proc = self._proc @@ -161,6 +163,33 @@ def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH): return path +def recording_command(target=""): + """Return a raw-s16 capture command for the sound server on this desktop. + + parec works with both PulseAudio and PipeWire's PulseAudio compatibility + service, and its source names are the same ones shown by list_sources(). + Keep pw-record as the fallback for minimal native-PipeWire installations. + """ + if shutil.which("parec"): + cmd = [ + "parec", "--record", "--raw", f"--rate={RATE}", + f"--channels={CHANNELS}", "--format=s16le", + ] + if target: + cmd.append(f"--device={target}") + return cmd + if shutil.which("pw-record"): + cmd = [ + "pw-record", "--raw", f"--rate={RATE}", + f"--channels={CHANNELS}", "--format=s16", + ] + if target: + cmd.append(f"--target={target}") + cmd.append("-") + return cmd + return [] + + class MeetingRecorder(QObject): """Microphone and speaker output into one stereo file: left is you, right is everyone else. diff --git a/hotkey.py b/hotkey.py index 77f1163..8347f50 100644 --- a/hotkey.py +++ b/hotkey.py @@ -1,10 +1,12 @@ -"""Global shortcut: KDE custom-shortcut installation plus a built-in evdev listener.""" +"""GNOME/KDE global-shortcut installation plus a built-in evdev listener.""" +import ast import glob import os import pathlib import re import select +import shutil import struct import subprocess import threading @@ -19,6 +21,8 @@ ASK_DESKTOP_ID = "dikte-ask.desktop" APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications" DESKTOP_FILE = APPLICATIONS_DIR / DESKTOP_ID SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc" +GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys" +GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding" # --- evdev key codes (linux/input-event-codes.h) -------------------------- @@ -174,6 +178,150 @@ class EvdevHotkey(QObject): # --- KDE custom shortcut -------------------------------------------------- +def _gnome(): + desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + return "gnome" in desktop and shutil.which("gsettings") is not None + + +def _gnome_path(desktop_id): + name = re.sub(r"[^a-zA-Z0-9_-]+", "-", desktop_id.removesuffix(".desktop")) + return f"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/{name}/" + + +def gnome_accelerator(shortcut): + """Translate Qt-style Ctrl+Alt+A into GNOME's a syntax.""" + parts = [part.strip() for part in str(shortcut).split("+") if part.strip()] + modifiers = [] + key = "" + names = { + "ctrl": "", "control": "", + "alt": "", "shift": "", + "super": "", "meta": "", + } + for part in parts: + modifier = names.get(part.lower()) + if modifier: + if modifier not in modifiers: + modifiers.append(modifier) + else: + key = part.lower() if len(part) == 1 else part + return "".join(modifiers) + key if key else "" + + +def display_accelerator(accelerator): + """Translate a GNOME accelerator back to the form shown in Dikte.""" + text = str(accelerator) + parts = [] + for token, label in (("", "Ctrl"), ("", "Ctrl"), + ("", "Alt"), ("", "Shift"), + ("", "Super")): + if token.lower() in text.lower(): + parts.append(label) + text = re.sub(re.escape(token), "", text, flags=re.IGNORECASE) + key = text.strip() + if len(key) == 1: + key = key.upper() + if key: + parts.append(key) + return "+".join(parts) + + +def _gsettings(*args, check=True): + return subprocess.run( + ["gsettings", *args], capture_output=True, text=True, timeout=10, check=check, + ) + + +def _gsettings_array(value): + """Parse a gsettings string-array, including the empty `@as []` form.""" + text = str(value).strip() + if text.startswith("@as "): + text = text[4:].strip() + parsed = ast.literal_eval(text) if text else [] + if not isinstance(parsed, (list, tuple)): + raise ValueError(f"not a string array: {value}") + return list(parsed) + + +def install_gnome_shortcut(shortcut, exec_command, + name="Dikte: start/stop recording", + desktop_id=DESKTOP_ID): + path = _gnome_path(desktop_id) + try: + current = _gsettings( + "get", GNOME_MEDIA_SCHEMA, "custom-keybindings" + ).stdout.strip() + paths = _gsettings_array(current) + if path not in paths: + paths.append(path) + _gsettings("set", GNOME_MEDIA_SCHEMA, "custom-keybindings", repr(paths)) + schema = f"{GNOME_BINDING_SCHEMA}:{path}" + _gsettings("set", schema, "name", repr(name)) + _gsettings("set", schema, "command", repr(exec_command)) + accelerator = gnome_accelerator(shortcut) + if not accelerator: + raise ValueError(t("Could not parse the shortcut: {shortcut}", + shortcut=shortcut)) + _gsettings("set", schema, "binding", repr(accelerator)) + except (ValueError, SyntaxError, subprocess.SubprocessError, OSError) as exc: + return False, t("Could not register the GNOME shortcut: {error}", error=exc) + return True, t("Shortcut saved: {shortcut}", shortcut=shortcut) + + +def remove_gnome_shortcut(desktop_id=DESKTOP_ID): + path = _gnome_path(desktop_id) + try: + current = _gsettings( + "get", GNOME_MEDIA_SCHEMA, "custom-keybindings" + ).stdout.strip() + paths = _gsettings_array(current) + if path in paths: + paths.remove(path) + _gsettings("set", GNOME_MEDIA_SCHEMA, "custom-keybindings", repr(paths)) + except (ValueError, SyntaxError, subprocess.SubprocessError, OSError): + pass + + +def gnome_shortcut_status(desktop_id=DESKTOP_ID): + path = _gnome_path(desktop_id) + try: + current = _gsettings( + "get", GNOME_MEDIA_SCHEMA, "custom-keybindings" + ).stdout.strip() + paths = _gsettings_array(current) + if path not in paths: + return None + value = _gsettings( + "get", f"{GNOME_BINDING_SCHEMA}:{path}", "binding" + ).stdout.strip() + accelerator = ast.literal_eval(value) + return display_accelerator(accelerator) if accelerator else None + except (ValueError, SyntaxError, subprocess.SubprocessError, OSError): + return None + + +def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", + desktop_id=DESKTOP_ID): + if _gnome(): + return install_gnome_shortcut(shortcut, exec_command, name, desktop_id) + return install_kde_shortcut(shortcut, exec_command, name, desktop_id) + + +def remove_shortcut(desktop_id=DESKTOP_ID): + if _gnome(): + remove_gnome_shortcut(desktop_id) + else: + remove_kde_shortcut(desktop_id) + + +def shortcut_status(desktop_id=DESKTOP_ID): + return (gnome_shortcut_status(desktop_id) if _gnome() + else kde_shortcut_status(desktop_id)) + + +def desktop_name(): + return "GNOME" if _gnome() else "KDE" + def install_kde_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", desktop_id=DESKTOP_ID): """Write the desktop file and the kglobalshortcutsrc entry. diff --git a/i18n.py b/i18n.py index 13ec3d2..ef80f4e 100644 --- a/i18n.py +++ b/i18n.py @@ -101,9 +101,20 @@ TR = { "pw-record not found. Is pipewire-audio installed?": "pw-record bulunamadı. pipewire-audio kurulu mu?", "Could not start recording: {error}": "Kayıt başlatılamadı: {error}", + "No audio recorder found. Install pulseaudio-utils or pipewire-audio.": + "Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.", + "Audio recorder stopped before receiving sound: {error}": + "Ses kayıt aracı veri alamadan kapandı: {error}", "wl-copy not found. Install wl-clipboard.": "wl-copy bulunamadı. wl-clipboard paketini kur.", "Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}", + "{tool} not found; clipboard copy is unavailable.": + "{tool} bulunamadı; panoya kopyalama kullanılamıyor.", + "{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.", + "{tool} not found, cannot paste automatically.": + "{tool} bulunamadı, otomatik yapıştırma yapılamıyor.", + "Could not run xdotool: {error}": "xdotool çalıştırılamadı: {error}", + "xdotool failed: {error}": "xdotool hatası: {error}", "wl-copy exited with code {code}.": "wl-copy {code} koduyla çıktı.", "ydotool not found, cannot paste automatically.": "ydotool bulunamadı, otomatik yapıştırma yapılamıyor.", @@ -265,9 +276,19 @@ TR = { # --- settings: shortcut ------------------------------------------------ "Install as a KDE shortcut": "KDE kısayolu olarak kur", + "Install as a global shortcut": "Global kısayol olarak kur", "Remove": "Kaldır", "Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}", "No KDE shortcut installed.": "KDE kısayolu kurulu değil.", + "Registered in {desktop}: {shortcut}": "{desktop}'da kayıtlı: {shortcut}", + "No global shortcut installed.": "Global kısayol kurulu değil.", + "No global shortcut installed. The tray menu starts a meeting too.": + "Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.", + "No global shortcut installed. The tray menu asks it too.": + "Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.", + "Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}", + "Could not register the GNOME shortcut: {error}": + "GNOME kısayolu kaydedilemedi: {error}", "Use the built-in listener (/dev/input), for when the KDE shortcut is not active yet": "Yerleşik dinleyici kullan (/dev/input), KDE kısayolu henüz etkin değilken", "Works immediately, no session restart. The only difference: the key " diff --git a/install.sh b/install.sh index 8aef023..2cbaaee 100755 --- a/install.sh +++ b/install.sh @@ -19,21 +19,33 @@ echo "────────────────" # 1. Dependencies ---------------------------------------------------------- missing=() -for cmd in pw-record wl-copy wl-paste ydotool ffmpeg; do +audio_cmds=(ffmpeg) +if command -v parec >/dev/null || command -v pw-record >/dev/null; then + : +else + missing+=("pulseaudio-utils-or-pipewire-audio") +fi +if [[ "${XDG_SESSION_TYPE:-}" == "x11" ]]; then + desktop_cmds=(xclip xdotool) +else + desktop_cmds=(wl-copy wl-paste ydotool) +fi +for cmd in "${audio_cmds[@]}" "${desktop_cmds[@]}"; do command -v "$cmd" >/dev/null || missing+=("$cmd") done python3 -c 'import PyQt6.QtWidgets' 2>/dev/null || missing+=("python-pyqt6") if ((${#missing[@]})); then warn "Missing: ${missing[*]}" - say "Arch/CachyOS: sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6" + say "Ubuntu X11: sudo apt install pulseaudio-utils xclip xdotool ffmpeg" + say "Arch Wayland: sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6" echo else ok "All dependencies present" fi # 2. ydotoold -------------------------------------------------------------- -if command -v ydotool >/dev/null; then +if [[ "${XDG_SESSION_TYPE:-}" != "x11" ]] && command -v ydotool >/dev/null; then if systemctl --user is-active --quiet ydotool 2>/dev/null \ || systemctl --user is-active --quiet ydotoold 2>/dev/null; then ok "ydotoold is running (auto-paste ready)" @@ -87,7 +99,10 @@ Type=Application X-KDE-GlobalAccel-CommandShortcut=true EOF -if command -v kwriteconfig6 >/dev/null; then +if [[ "${XDG_CURRENT_DESKTOP:-}" == *GNOME* || "${XDG_CURRENT_DESKTOP:-}" == *gnome* ]]; then + ok "GNOME detected" + say "Open Dikte Settings > Shortcut to install the global shortcut." +elif command -v kwriteconfig6 >/dev/null; then kwriteconfig6 --notify --file kglobalshortcutsrc \ --group services --group dikte-toggle.desktop \ --key _launch "$SHORTCUT" @@ -96,10 +111,10 @@ if command -v kwriteconfig6 >/dev/null; then say "next login. Until then open Settings → Shortcut and turn on the" say "built-in listener to use it right away." else - warn "kwriteconfig6 not found. Add the shortcut via System Settings > Shortcuts" + warn "No supported shortcut manager found. Add the shortcut in desktop settings." fi echo ok "Done. Start it with: dikte" -say "The settings window opens on first run; add your OpenAI and OpenRouter keys." +say "The settings window opens on first run; add an OpenAI, Groq or OpenRouter key." echo diff --git a/paste.py b/paste.py index 2065d56..b87d71d 100644 --- a/paste.py +++ b/paste.py @@ -1,5 +1,6 @@ -"""Clipboard (wl-clipboard) and key injection (ydotool).""" +"""Clipboard and key injection for Wayland and X11.""" +import os import shutil import subprocess import time @@ -18,20 +19,23 @@ class PasteError(Exception): def read_clipboard(): - if not shutil.which("wl-paste"): + command = (["xclip", "-selection", "clipboard", "-out"] if _x11() + else ["wl-paste", "--no-newline"]) + if not shutil.which(command[0]): return None try: - res = subprocess.run(["wl-paste", "--no-newline"], capture_output=True, timeout=5) + res = subprocess.run(command, capture_output=True, timeout=5) except (subprocess.SubprocessError, OSError): return None return res.stdout if res.returncode == 0 else None -def _run_wl_copy(payload): - """wl-copy forks to keep owning the selection; leaving its pipes open makes - subprocess.run wait for EOF forever, hence DEVNULL.""" +def _run_copy(payload): + """The clipboard owner may fork; do not leave inherited pipes open.""" + command = (["xclip", "-selection", "clipboard", "-in"] if _x11() + else ["wl-copy"]) return subprocess.run( - ["wl-copy"], + command, input=payload, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -40,33 +44,58 @@ def _run_wl_copy(payload): def copy(text): - if not shutil.which("wl-copy"): - raise PasteError(t("wl-copy not found. Install wl-clipboard.")) + tool = "xclip" if _x11() else "wl-copy" + if not shutil.which(tool): + raise PasteError(t("{tool} not found; clipboard copy is unavailable.", tool=tool)) try: - res = _run_wl_copy(text.encode("utf-8")) + res = _run_copy(text.encode("utf-8")) except (subprocess.SubprocessError, OSError) as exc: raise PasteError(t("Could not copy to clipboard: {error}", error=exc)) from exc if res.returncode != 0: - raise PasteError(t("wl-copy exited with code {code}.", code=res.returncode)) + raise PasteError(t("{tool} exited with code {code}.", + tool=tool, code=res.returncode)) def copy_bytes(data): - if data is None or not shutil.which("wl-copy"): + tool = "xclip" if _x11() else "wl-copy" + if data is None or not shutil.which(tool): return try: - _run_wl_copy(data) + _run_copy(data) except (subprocess.SubprocessError, OSError): pass def ydotool_ready(): - return shutil.which("ydotool") is not None + tool = "xdotool" if _x11() else "ydotool" + return shutil.which(tool) is not None + + +def _x11(): + return (os.environ.get("XDG_SESSION_TYPE") == "x11" + or bool(os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"))) def press(shortcut="ctrl+v", delay=0.12): - """Press a key combination through ydotool, e.g. 'ctrl+v'.""" + """Press a key combination through xdotool or ydotool.""" if not ydotool_ready(): - raise PasteError(t("ydotool not found, cannot paste automatically.")) + tool = "xdotool" if _x11() else "ydotool" + raise PasteError(t("{tool} not found, cannot paste automatically.", tool=tool)) + + if _x11(): + key = shortcut.lower().replace("control", "ctrl") + time.sleep(delay) + try: + res = subprocess.run( + ["xdotool", "key", "--clearmodifiers", key], + capture_output=True, text=True, timeout=10, + ) + except (subprocess.SubprocessError, OSError) as exc: + raise PasteError(t("Could not run xdotool: {error}", error=exc)) from exc + if res.returncode != 0: + raise PasteError(t("xdotool failed: {error}", + error=res.stderr.strip() or "unknown error")) + return codes = [] for key in (k.strip().lower() for k in shortcut.split("+") if k.strip()): diff --git a/tests/test_desktop_compat.py b/tests/test_desktop_compat.py new file mode 100644 index 0000000..150944b --- /dev/null +++ b/tests/test_desktop_compat.py @@ -0,0 +1,55 @@ +import os +import unittest +from unittest import mock + +import audio +import hotkey +import paste + + +class AudioBackendTests(unittest.TestCase): + def test_parec_is_preferred(self): + with mock.patch.object(audio.shutil, "which", side_effect=lambda cmd: f"/usr/bin/{cmd}"): + self.assertEqual(audio.recording_command()[0], "parec") + + def test_pw_record_remains_the_fallback(self): + with mock.patch.object( + audio.shutil, "which", side_effect=lambda cmd: "/usr/bin/pw-record" + if cmd == "pw-record" else None, + ): + self.assertEqual(audio.recording_command()[0], "pw-record") + + +class DesktopBackendTests(unittest.TestCase): + def test_x11_uses_xclip(self): + result = mock.Mock(returncode=0) + with mock.patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}), \ + mock.patch.object(paste.shutil, "which", return_value="/usr/bin/xclip"), \ + mock.patch.object(paste.subprocess, "run", return_value=result) as run: + paste.copy("hello") + self.assertEqual(run.call_args.args[0][:3], + ["xclip", "-selection", "clipboard"]) + + def test_x11_uses_xdotool(self): + result = mock.Mock(returncode=0, stderr="") + with mock.patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}), \ + mock.patch.object(paste.shutil, "which", return_value="/usr/bin/xdotool"), \ + mock.patch.object(paste.time, "sleep"), \ + mock.patch.object(paste.subprocess, "run", return_value=result) as run: + paste.press("ctrl+shift+v") + self.assertEqual(run.call_args.args[0], + ["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"]) + + +class GnomeShortcutTests(unittest.TestCase): + def test_accelerator_round_trip(self): + accelerator = hotkey.gnome_accelerator("Ctrl+Alt+A") + self.assertEqual(accelerator, "a") + self.assertEqual(hotkey.display_accelerator(accelerator), "Ctrl+Alt+A") + + def test_empty_gsettings_array(self): + self.assertEqual(hotkey._gsettings_array("@as []"), []) + + +if __name__ == "__main__": + unittest.main()