Add GNOME X11 and PulseAudio support

This commit is contained in:
Muzaffer Emre
2026-07-31 18:47:49 +03:00
parent da3dc3c908
commit d64ed8968a
8 changed files with 353 additions and 42 deletions
+9 -2
View File
@@ -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
+9 -2
View File
@@ -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`),
+44 -15
View File
@@ -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.
+149 -1
View File
@@ -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 <Primary><Alt>a syntax."""
parts = [part.strip() for part in str(shortcut).split("+") if part.strip()]
modifiers = []
key = ""
names = {
"ctrl": "<Primary>", "control": "<Primary>",
"alt": "<Alt>", "shift": "<Shift>",
"super": "<Super>", "meta": "<Super>",
}
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 (("<Primary>", "Ctrl"), ("<Control>", "Ctrl"),
("<Alt>", "Alt"), ("<Shift>", "Shift"),
("<Super>", "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.
+21
View File
@@ -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 "
+21 -6
View File
@@ -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
+45 -16
View File
@@ -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()):
+55
View File
@@ -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, "<Primary><Alt>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()