diff --git a/AGENTS.md b/AGENTS.md index 03488b5..1b50d91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,19 @@ - `src/recorder.py` handles audio capture using PortAudio via `sounddevice`. - `src/leld.py` owns Whisper setup and transcription. - `src/aiprocess.py` runs the in-process Llama-3.2-3B cleanup. +- `src/desktop_x11.py` encapsulates X11 hotkeys, tray, and injection. +- `src/desktop_wayland.py` scaffolds Wayland support (exits with a message). ## Build, Test, and Development Commands -- Install deps: `uv sync`. +- Install deps (X11): `uv sync --extra x11`. +- Install deps (Wayland scaffold): `uv sync --extra wayland`. - Run daemon: `uv run python3 src/leld.py --config ~/.config/lel/config.json`. -System packages (example names): `portaudio`/`libportaudio2`, `libayatana-appindicator3`. +System packages (example names): + +- Core: `portaudio`/`libportaudio2`. +- X11 tray: `libayatana-appindicator3`. ## Coding Style & Naming Conventions diff --git a/README.md b/README.md index 940fe38..7ce065d 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,75 @@ Python X11 STT daemon that records audio, runs Whisper, logs the transcript, and ## Requirements -- X11 (not Wayland) +- X11 (Wayland support scaffolded but not available yet) - `sounddevice` (PortAudio) - `faster-whisper` - `llama-cpp-python` - Tray icon deps: `gtk3`, `libayatana-appindicator3` -- Python deps: `pillow`, `python-xlib`, `faster-whisper`, `llama-cpp-python`, `PyGObject`, `sounddevice` +- Python deps (core): `pillow`, `faster-whisper`, `llama-cpp-python`, `sounddevice` +- X11 extras: `PyGObject`, `python-xlib` System packages (example names): `portaudio`/`libportaudio2`. +
+Ubuntu (X11) + +```bash +sudo apt install -y portaudio19-dev libportaudio2 python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-1 +``` + +
+ +
+Debian (X11) + +```bash +sudo apt install -y portaudio19-dev libportaudio2 python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-1 +``` + +
+ +
+Arch Linux (X11) + +```bash +sudo pacman -S --needed portaudio gtk3 libayatana-appindicator +``` + +
+ +
+Fedora (X11) + +```bash +sudo dnf install -y portaudio portaudio-devel gtk3 libayatana-appindicator-gtk3 +``` + +
+ +
+openSUSE (X11) + +```bash +sudo zypper install -y portaudio portaudio-devel gtk3 libayatana-appindicator3-1 +``` + +
+ ## Python Daemon Install Python deps: +X11 (supported): + ```bash -uv sync +uv sync --extra x11 +``` + +Wayland (scaffold only): + +```bash +uv sync --extra wayland ``` Run: @@ -64,6 +118,10 @@ systemctl --user enable --now lel - Press it again to stop and run STT. - The transcript is logged to stderr. +Wayland note: + +- Running under Wayland currently exits with a message explaining that it is not supported yet. + Injection backends: - `clipboard`: copy to clipboard and inject via Ctrl+Shift+V (GTK clipboard + XTest) diff --git a/pyproject.toml b/pyproject.toml index 8c427ff..d26c21c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,15 @@ dependencies = [ "faster-whisper", "llama-cpp-python", "pillow", - "python-xlib", - "PyGObject", "sounddevice", ] +[project.optional-dependencies] +x11 = [ + "PyGObject", + "python-xlib", +] +wayland = [] + [tool.uv] package = false diff --git a/src/desktop.py b/src/desktop.py new file mode 100644 index 0000000..a20c704 --- /dev/null +++ b/src/desktop.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import os +from typing import Callable, Protocol + + +class DesktopAdapter(Protocol): + def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None: + raise NotImplementedError + + def inject_text(self, text: str, backend: str) -> None: + raise NotImplementedError + + def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None: + raise NotImplementedError + + +def get_desktop_adapter() -> DesktopAdapter: + session_type = os.getenv("XDG_SESSION_TYPE", "").lower() + if session_type == "wayland" or os.getenv("WAYLAND_DISPLAY"): + from desktop_wayland import WaylandAdapter + + raise SystemExit( + "Wayland is not supported yet. Run under X11 (XDG_SESSION_TYPE=x11) to use lel." + ) + from desktop_x11 import X11Adapter + + return X11Adapter() diff --git a/src/desktop_wayland.py b/src/desktop_wayland.py new file mode 100644 index 0000000..5506791 --- /dev/null +++ b/src/desktop_wayland.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Callable + + +class WaylandAdapter: + def start_hotkey_listener(self, _hotkey: str, _callback: Callable[[], None]) -> None: + raise SystemExit("Wayland hotkeys are not supported yet.") + + def inject_text(self, _text: str, _backend: str) -> None: + raise SystemExit("Wayland text injection is not supported yet.") + + def run_tray(self, _state_getter: Callable[[], str], _on_quit: Callable[[], None]) -> None: + raise SystemExit("Wayland tray support is not available yet.") diff --git a/src/desktop_x11.py b/src/desktop_x11.py new file mode 100644 index 0000000..ff3ac1c --- /dev/null +++ b/src/desktop_x11.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import logging +import threading +import warnings +from pathlib import Path +from typing import Callable, Iterable + +import gi +from Xlib import X, XK, display +from Xlib.ext import xtest + +gi.require_version("Gtk", "3.0") +try: + gi.require_version("AppIndicator3", "0.1") + from gi.repository import AppIndicator3 # type: ignore[import-not-found] +except ValueError: + AppIndicator3 = None + +from gi.repository import GLib, Gdk, Gtk # type: ignore[import-not-found] + + +ASSETS_DIR = Path(__file__).parent / "assets" +TRAY_UPDATE_MS = 250 + +MOD_MAP = { + "shift": X.ShiftMask, + "ctrl": X.ControlMask, + "control": X.ControlMask, + "alt": X.Mod1Mask, + "mod1": X.Mod1Mask, + "super": X.Mod4Mask, + "mod4": X.Mod4Mask, + "cmd": X.Mod4Mask, + "command": X.Mod4Mask, +} + + +class X11Adapter: + def __init__(self): + self.indicator = None + self.status_icon = None + self.menu = None + if AppIndicator3 is not None: + self.indicator = AppIndicator3.Indicator.new( + "lel", + self._icon_path("idle"), + AppIndicator3.IndicatorCategory.APPLICATION_STATUS, + ) + self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) + else: + logging.warning("AppIndicator3 unavailable; falling back to deprecated Gtk.StatusIcon") + warnings.filterwarnings( + "ignore", + message=".*Gtk.StatusIcon.*", + category=DeprecationWarning, + ) + self.status_icon = Gtk.StatusIcon() + self.status_icon.set_visible(True) + self.status_icon.connect("popup-menu", self._on_tray_menu) + + def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None: + thread = threading.Thread(target=self._listen, args=(hotkey, callback), daemon=True) + thread.start() + + def inject_text(self, text: str, backend: str) -> None: + backend = (backend or "").strip().lower() + if backend in ("", "clipboard"): + self._write_clipboard(text) + self._paste_clipboard() + return + if backend == "injection": + self._type_text(text) + return + raise ValueError(f"unknown injection backend: {backend}") + + def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None: + self.menu = Gtk.Menu() + quit_item = Gtk.MenuItem(label="Quit") + quit_item.connect("activate", lambda *_: on_quit()) + self.menu.append(quit_item) + self.menu.show_all() + if self.indicator is not None: + self.indicator.set_menu(self.menu) + + self._update_tray(state_getter) + GLib.timeout_add(TRAY_UPDATE_MS, self._update_tray, state_getter) + Gtk.main() + + def _listen(self, hotkey: str, callback: Callable[[], None]) -> None: + disp = display.Display() + root = disp.screen().root + mods, keysym = self._parse_hotkey(hotkey) + keycode = self._grab_hotkey(disp, root, mods, keysym) + try: + while True: + ev = disp.next_event() + if ev.type == X.KeyPress and ev.detail == keycode: + state = ev.state & ~(X.LockMask | X.Mod2Mask) + if state == mods: + callback() + finally: + try: + root.ungrab_key(keycode, X.AnyModifier) + disp.sync() + except Exception: + pass + + def _parse_hotkey(self, hotkey: str): + parts = [p.strip() for p in hotkey.split("+") if p.strip()] + mods = 0 + key_part = None + for p in parts: + low = p.lower() + if low in MOD_MAP: + mods |= MOD_MAP[low] + else: + key_part = p + if not key_part: + raise ValueError("hotkey missing key") + + keysym = XK.string_to_keysym(key_part) + if keysym == 0 and len(key_part) == 1: + keysym = ord(key_part) + if keysym == 0: + raise ValueError(f"unsupported key: {key_part}") + return mods, keysym + + def _grab_hotkey(self, disp, root, mods, keysym): + keycode = disp.keysym_to_keycode(keysym) + root.grab_key(keycode, mods, True, X.GrabModeAsync, X.GrabModeAsync) + root.grab_key(keycode, mods | X.LockMask, True, X.GrabModeAsync, X.GrabModeAsync) + root.grab_key(keycode, mods | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync) + root.grab_key(keycode, mods | X.LockMask | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync) + disp.sync() + return keycode + + def _write_clipboard(self, text: str) -> None: + Gtk.init([]) + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(text, -1) + clipboard.store() + while Gtk.events_pending(): + Gtk.main_iteration() + + def _paste_clipboard(self) -> None: + dpy = display.Display() + self._send_combo(dpy, ["Control_L", "Shift_L", "v"]) + + def _type_text(self, text: str) -> None: + if not text: + return + dpy = display.Display() + for ch in text: + if ch == "\n": + self._send_combo(dpy, ["Return"]) + continue + keysym, needs_shift = self._keysym_for_char(ch) + if keysym is None: + continue + if needs_shift: + self._send_combo(dpy, ["Shift_L", keysym], already_keysym=True) + else: + self._send_combo(dpy, [keysym], already_keysym=True) + + def _send_combo(self, dpy: display.Display, keys: Iterable[str], already_keysym: bool = False) -> None: + keycodes: list[int] = [] + for key in keys: + keysym = key if already_keysym else XK.string_to_keysym(key) + if keysym == 0: + continue + keycode = dpy.keysym_to_keycode(keysym) + if keycode == 0: + continue + keycodes.append(keycode) + for code in keycodes: + xtest.fake_input(dpy, X.KeyPress, code) + for code in reversed(keycodes): + xtest.fake_input(dpy, X.KeyRelease, code) + dpy.flush() + + def _keysym_for_char(self, ch: str) -> tuple[int | None, bool]: + if ch.isupper(): + base = ch.lower() + keysym = XK.string_to_keysym(base) + return (keysym if keysym != 0 else None, True) + if ch in _SHIFTED: + keysym = XK.string_to_keysym(_SHIFTED[ch]) + return (keysym if keysym != 0 else None, True) + if ch == " ": + return (XK.string_to_keysym("space"), False) + keysym = XK.string_to_keysym(ch) + return (keysym if keysym != 0 else None, False) + + def _icon_path(self, state: str) -> str: + if state == "recording": + return str(ASSETS_DIR / "recording.png") + if state == "stt": + return str(ASSETS_DIR / "stt.png") + if state == "processing": + return str(ASSETS_DIR / "processing.png") + return str(ASSETS_DIR / "idle.png") + + def _title(self, state: str) -> str: + if state == "recording": + return "Recording" + if state == "stt": + return "STT" + if state == "processing": + return "AI Processing" + return "Idle" + + def _update_tray(self, state_getter: Callable[[], str]): + state = state_getter() + icon_path = self._icon_path(state) + if self.indicator is not None: + self.indicator.set_icon_full(icon_path, self._title(state)) + self.indicator.set_label(self._title(state), "") + elif self.status_icon is not None: + self.status_icon.set_from_file(icon_path) + self.status_icon.set_tooltip_text(self._title(state)) + return True + + +_SHIFTED = { + "!": "1", + "@": "2", + "#": "3", + "$": "4", + "%": "5", + "^": "6", + "&": "7", + "*": "8", + "(": "9", + ")": "0", + "_": "-", + "+": "=", + "{": "[", + "}": "]", + "|": "\\", + ":": ";", + "\"": "'", + "<": ",", + ">": ".", + "?": "/", +} diff --git a/src/inject.py b/src/inject.py deleted file mode 100644 index 91017f7..0000000 --- a/src/inject.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -import gi - -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") - -from gi.repository import Gdk, Gtk -from Xlib import X, XK, display -from Xlib.ext import xtest - - -def write_clipboard(text: str) -> None: - Gtk.init([]) - clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - clipboard.set_text(text, -1) - clipboard.store() - while Gtk.events_pending(): - Gtk.main_iteration() - - -def paste_clipboard() -> None: - dpy = display.Display() - _send_combo(dpy, ["Control_L", "Shift_L", "v"]) - - -def type_text(text: str) -> None: - if not text: - return - dpy = display.Display() - for ch in text: - if ch == "\n": - _send_combo(dpy, ["Return"]) - continue - keysym, needs_shift = _keysym_for_char(ch) - if keysym is None: - continue - if needs_shift: - _send_combo(dpy, ["Shift_L", keysym], already_keysym=True) - else: - _send_combo(dpy, [keysym], already_keysym=True) - - -def inject(text: str, backend: str) -> None: - backend = (backend or "").strip().lower() - if backend in ("", "clipboard"): - write_clipboard(text) - paste_clipboard() - return - if backend == "injection": - type_text(text) - return - raise ValueError(f"unknown injection backend: {backend}") - - -def _send_combo(dpy: display.Display, keys: Iterable[str], already_keysym: bool = False) -> None: - keycodes: list[int] = [] - for key in keys: - keysym = key if already_keysym else XK.string_to_keysym(key) - if keysym == 0: - continue - keycode = dpy.keysym_to_keycode(keysym) - if keycode == 0: - continue - keycodes.append(keycode) - for code in keycodes: - xtest.fake_input(dpy, X.KeyPress, code) - for code in reversed(keycodes): - xtest.fake_input(dpy, X.KeyRelease, code) - dpy.flush() - - -_SHIFTED = { - "!": "1", - "@": "2", - "#": "3", - "$": "4", - "%": "5", - "^": "6", - "&": "7", - "*": "8", - "(": "9", - ")": "0", - "_": "-", - "+": "=", - "{": "[", - "}": "]", - "|": "\\", - ":": ";", - "\"": "'", - "<": ",", - ">": ".", - "?": "/", -} - - -def _keysym_for_char(ch: str) -> tuple[int | None, bool]: - if ch.isupper(): - base = ch.lower() - keysym = XK.string_to_keysym(base) - return (keysym if keysym != 0 else None, True) - if ch in _SHIFTED: - keysym = XK.string_to_keysym(_SHIFTED[ch]) - return (keysym if keysym != 0 else None, True) - if ch == " ": - return (XK.string_to_keysym("space"), False) - keysym = XK.string_to_keysym(ch) - return (keysym if keysym != 0 else None, False) diff --git a/src/leld.py b/src/leld.py index b83a379..68bc243 100755 --- a/src/leld.py +++ b/src/leld.py @@ -7,7 +7,6 @@ import signal import sys import threading import time -import warnings from pathlib import Path import gi @@ -16,17 +15,7 @@ from faster_whisper import WhisperModel from config import Config, load, redacted_dict from recorder import start_recording, stop_recording from aiprocess import build_processor -from inject import inject -from x11_hotkey import listen - -gi.require_version("Gtk", "3.0") -try: - gi.require_version("AppIndicator3", "0.1") - from gi.repository import AppIndicator3 # type: ignore[import-not-found] -except ValueError: - AppIndicator3 = None - -from gi.repository import GLib, Gtk # type: ignore[import-not-found] +from desktop import get_desktop_adapter class State: @@ -37,10 +26,8 @@ class State: OUTPUTTING = "outputting" -ASSETS_DIR = Path(__file__).parent / "assets" RECORD_TIMEOUT_SEC = 300 STT_LANGUAGE = "en" -TRAY_UPDATE_MS = 250 def _compute_type(device: str) -> str: @@ -51,8 +38,9 @@ def _compute_type(device: str) -> str: class Daemon: - def __init__(self, cfg: Config, *, llama_verbose: bool = False): + def __init__(self, cfg: Config, desktop, *, llama_verbose: bool = False): self.cfg = cfg + self.desktop = desktop self.lock = threading.Lock() self.state = State.IDLE self.proc = None @@ -64,32 +52,6 @@ class Daemon: compute_type=_compute_type(cfg.stt.get("device", "cpu")), ) self.ai_processor = build_processor(verbose=llama_verbose) - self.indicator = None - self.status_icon = None - if AppIndicator3 is not None: - self.indicator = AppIndicator3.Indicator.new( - "lel", - self._icon_path(State.IDLE), - AppIndicator3.IndicatorCategory.APPLICATION_STATUS, - ) - self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) - else: - logging.warning("AppIndicator3 unavailable; falling back to deprecated Gtk.StatusIcon") - warnings.filterwarnings( - "ignore", - message=".*Gtk.StatusIcon.*", - category=DeprecationWarning, - ) - self.status_icon = Gtk.StatusIcon() - self.status_icon.set_visible(True) - self.status_icon.connect("popup-menu", self._on_tray_menu) - self.menu = Gtk.Menu() - quit_item = Gtk.MenuItem(label="Quit") - quit_item.connect("activate", lambda *_: self._quit()) - self.menu.append(quit_item) - self.menu.show_all() - if self.indicator is not None: - self.indicator.set_menu(self.menu) def set_state(self, state: str): with self.lock: @@ -105,9 +67,6 @@ class Daemon: def _quit(self): os._exit(0) - def _on_tray_menu(self, _icon, _button, _time): - self.menu.popup(None, None, None, None, 0, _time) - def toggle(self): with self.lock: if self.state == State.IDLE: @@ -198,7 +157,7 @@ class Daemon: self.set_state(State.OUTPUTTING) logging.info("outputting started") backend = self.cfg.injection.get("backend", "clipboard") - inject(text, backend) + self.desktop.inject_text(text, backend) except Exception as exc: logging.error("output failed: %s", exc) finally: @@ -221,41 +180,6 @@ class Daemon: parts.append(text) return " ".join(parts).strip() - def _icon_path(self, state: str) -> str: - if state == State.RECORDING: - return str(ASSETS_DIR / "recording.png") - if state == State.STT: - return str(ASSETS_DIR / "stt.png") - if state == State.PROCESSING: - return str(ASSETS_DIR / "processing.png") - return str(ASSETS_DIR / "idle.png") - - def _title(self, state: str) -> str: - if state == State.RECORDING: - return "Recording" - if state == State.STT: - return "STT" - if state == State.PROCESSING: - return "AI Processing" - return "Idle" - - def _update_tray(self): - state = self.get_state() - icon_path = self._icon_path(state) - if self.indicator is not None: - self.indicator.set_icon_full(icon_path, self._title(state)) - self.indicator.set_label(self._title(state), "") - elif self.status_icon is not None: - self.status_icon.set_from_file(icon_path) - self.status_icon.set_tooltip_text(self._title(state)) - return True - - def run_tray(self): - self._update_tray() - GLib.timeout_add(TRAY_UPDATE_MS, self._update_tray) - Gtk.main() - - def _lock_single_instance(): runtime_dir = Path(os.getenv("XDG_RUNTIME_DIR", "/tmp")) / "lel" runtime_dir.mkdir(parents=True, exist_ok=True) @@ -291,8 +215,9 @@ def main(): if args.verbose: logging.getLogger().setLevel(logging.DEBUG) + desktop = get_desktop_adapter() try: - daemon = Daemon(cfg, llama_verbose=args.verbose) + daemon = Daemon(cfg, desktop, llama_verbose=args.verbose) except Exception as exc: logging.error("startup failed: %s", exc) raise SystemExit(1) @@ -308,14 +233,11 @@ def main(): signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGTERM, handle_signal) - threading.Thread( - target=lambda: listen( - cfg.daemon.get("hotkey", ""), - lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(), - ), - daemon=True, - ).start() - daemon.run_tray() + desktop.start_hotkey_listener( + cfg.daemon.get("hotkey", ""), + lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(), + ) + desktop.run_tray(daemon.get_state, daemon._quit) if __name__ == "__main__": diff --git a/src/x11_hotkey.py b/src/x11_hotkey.py deleted file mode 100644 index a11b759..0000000 --- a/src/x11_hotkey.py +++ /dev/null @@ -1,67 +0,0 @@ -from Xlib import X, display -from Xlib import XK - -MOD_MAP = { - "shift": X.ShiftMask, - "ctrl": X.ControlMask, - "control": X.ControlMask, - "alt": X.Mod1Mask, - "mod1": X.Mod1Mask, - "super": X.Mod4Mask, - "mod4": X.Mod4Mask, - "cmd": X.Mod4Mask, - "command": X.Mod4Mask, -} - - -def parse_hotkey(hotkey: str): - parts = [p.strip() for p in hotkey.split("+") if p.strip()] - mods = 0 - key_part = None - for p in parts: - low = p.lower() - if low in MOD_MAP: - mods |= MOD_MAP[low] - else: - key_part = p - if not key_part: - raise ValueError("hotkey missing key") - - keysym = XK.string_to_keysym(key_part) - if keysym == 0 and len(key_part) == 1: - keysym = ord(key_part) - if keysym == 0: - raise ValueError(f"unsupported key: {key_part}") - - return mods, keysym - - -def grab_hotkey(disp, root, mods, keysym): - keycode = disp.keysym_to_keycode(keysym) - root.grab_key(keycode, mods, True, X.GrabModeAsync, X.GrabModeAsync) - # ignore CapsLock/NumLock - root.grab_key(keycode, mods | X.LockMask, True, X.GrabModeAsync, X.GrabModeAsync) - root.grab_key(keycode, mods | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync) - root.grab_key(keycode, mods | X.LockMask | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync) - disp.sync() - return keycode - - -def listen(hotkey: str, on_trigger): - disp = display.Display() - root = disp.screen().root - mods, keysym = parse_hotkey(hotkey) - keycode = grab_hotkey(disp, root, mods, keysym) - try: - while True: - ev = disp.next_event() - if ev.type == X.KeyPress and ev.detail == keycode: - state = ev.state & ~(X.LockMask | X.Mod2Mask) - if state == mods: - on_trigger() - finally: - try: - root.ungrab_key(keycode, X.AnyModifier) - disp.sync() - except Exception: - pass diff --git a/uv.lock b/uv.lock index 832728f..64838b6 100644 --- a/uv.lock +++ b/uv.lock @@ -411,9 +411,13 @@ dependencies = [ { name = "faster-whisper" }, { name = "llama-cpp-python" }, { name = "pillow" }, + { name = "sounddevice" }, +] + +[package.optional-dependencies] +x11 = [ { name = "pygobject" }, { name = "python-xlib" }, - { name = "sounddevice" }, ] [package.metadata] @@ -421,10 +425,11 @@ requires-dist = [ { name = "faster-whisper" }, { name = "llama-cpp-python" }, { name = "pillow" }, - { name = "pygobject" }, - { name = "python-xlib" }, + { name = "pygobject", marker = "extra == 'x11'" }, + { name = "python-xlib", marker = "extra == 'x11'" }, { name = "sounddevice" }, ] +provides-extras = ["x11", "wayland"] [[package]] name = "llama-cpp-python"