diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f60453d..a69debd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: libayatana-appindicator3-1 - name: Create project environment run: | - python -m venv .venv + python -m venv --system-site-packages .venv . .venv/bin/activate python -m pip install --upgrade pip python -m pip install uv build @@ -69,7 +69,7 @@ jobs: xvfb - name: Create project environment run: | - python -m venv .venv + python -m venv --system-site-packages .venv . .venv/bin/activate python -m pip install --upgrade pip python -m pip install uv build @@ -113,7 +113,7 @@ jobs: libayatana-appindicator3-1 - name: Create project environment run: | - python -m venv .venv + python -m venv --system-site-packages .venv . .venv/bin/activate python -m pip install --upgrade pip python -m pip install uv build diff --git a/AGENTS.md b/AGENTS.md index 606c064..da6611c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,12 +15,13 @@ ## Build, Test, and Development Commands -- Install deps (X11): `uv sync`. +- Install deps (X11): `python3 -m venv --system-site-packages .venv && . .venv/bin/activate && uv sync --active`. - Run daemon: `uv run aman run --config ~/.config/aman/config.json`. System packages (example names): - Core: `portaudio`/`libportaudio2`. +- GTK/X11 Python bindings: distro packages such as `python3-gi` / `python3-xlib`. - X11 tray: `libayatana-appindicator3`. ## Coding Style & Naming Conventions diff --git a/Makefile b/Makefile index 3db6c38..fcc1172 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,11 @@ check-default-model: uv run aman-maint sync-default-model --check --report $(EVAL_OUTPUT) --artifacts $(MODEL_ARTIFACTS) --constants $(CONSTANTS_FILE) sync: - uv sync + @if [ ! -f .venv/pyvenv.cfg ] || ! grep -q '^include-system-site-packages = true' .venv/pyvenv.cfg; then \ + rm -rf .venv; \ + $(PYTHON) -m venv --system-site-packages .venv; \ + fi + UV_PROJECT_ENVIRONMENT=$(CURDIR)/.venv uv sync test: $(PYTHON) -m unittest discover -s tests -p 'test_*.py' diff --git a/docs/developer-workflows.md b/docs/developer-workflows.md index 1f9cbdd..9602e5c 100644 --- a/docs/developer-workflows.md +++ b/docs/developer-workflows.md @@ -36,10 +36,15 @@ For `1.0.0`, the manual publication target is the forge release page at `uv` workflow: ```bash -uv sync +python3 -m venv --system-site-packages .venv +. .venv/bin/activate +uv sync --active uv run aman run --config ~/.config/aman/config.json ``` +Install the documented distro runtime dependencies first so the active virtualenv +can see GTK/AppIndicator/X11 bindings from the system Python. + `pip` workflow: ```bash diff --git a/packaging/arch/PKGBUILD.in b/packaging/arch/PKGBUILD.in index 3eb3194..b6ce02f 100644 --- a/packaging/arch/PKGBUILD.in +++ b/packaging/arch/PKGBUILD.in @@ -15,22 +15,16 @@ prepare() { cd "${srcdir}/aman-${pkgver}" python -m build --wheel python - <<'PY' +import ast from pathlib import Path import re -import tomllib -project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) -exclude = {"pygobject", "python-xlib"} -dependencies = project.get("project", {}).get("dependencies", []) -filtered = [] -for dependency in dependencies: - match = re.match(r"\s*([A-Za-z0-9_.-]+)", dependency) - if not match: - continue - name = match.group(1).lower().replace("_", "-") - if name in exclude: - continue - filtered.append(dependency.strip()) +text = Path("pyproject.toml").read_text(encoding="utf-8") +match = re.search(r"(?ms)^\s*dependencies\s*=\s*\[(.*?)^\s*\]", text) +if not match: + raise SystemExit("project dependencies not found in pyproject.toml") +dependencies = ast.literal_eval("[" + match.group(1) + "]") +filtered = [dependency.strip() for dependency in dependencies] Path("dist/runtime-requirements.txt").write_text("\n".join(filtered) + "\n", encoding="utf-8") PY } diff --git a/pyproject.toml b/pyproject.toml index 326f777..de20737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,6 @@ dependencies = [ "faster-whisper", "llama-cpp-python", "numpy", - "PyGObject", - "python-xlib", "sounddevice", ] @@ -58,6 +56,9 @@ py-modules = [ "aman_runtime", "config", "config_ui", + "config_ui_audio", + "config_ui_pages", + "config_ui_runtime", "constants", "desktop", "desktop_x11", diff --git a/scripts/package_common.sh b/scripts/package_common.sh index f9a13f9..62e1b4d 100755 --- a/scripts/package_common.sh +++ b/scripts/package_common.sh @@ -93,24 +93,18 @@ write_runtime_requirements() { local output_path="$1" require_command python3 python3 - "${output_path}" <<'PY' +import ast from pathlib import Path import re import sys -import tomllib output_path = Path(sys.argv[1]) -exclude = {"pygobject", "python-xlib"} -project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) -dependencies = project.get("project", {}).get("dependencies", []) -filtered = [] -for dependency in dependencies: - match = re.match(r"\s*([A-Za-z0-9_.-]+)", dependency) - if not match: - continue - name = match.group(1).lower().replace("_", "-") - if name in exclude: - continue - filtered.append(dependency.strip()) +text = Path("pyproject.toml").read_text(encoding="utf-8") +match = re.search(r"(?ms)^\s*dependencies\s*=\s*\[(.*?)^\s*\]", text) +if not match: + raise SystemExit("project dependencies not found in pyproject.toml") +dependencies = ast.literal_eval("[" + match.group(1) + "]") +filtered = [dependency.strip() for dependency in dependencies] output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text("\n".join(filtered) + "\n", encoding="utf-8") PY diff --git a/scripts/package_portable.sh b/scripts/package_portable.sh index 856df1c..314eec8 100755 --- a/scripts/package_portable.sh +++ b/scripts/package_portable.sh @@ -49,21 +49,16 @@ export_requirements() { --python "${python_version}" >"${raw_path}" python3 - "${raw_path}" "${output_path}" <<'PY' from pathlib import Path -import re import sys raw_path = Path(sys.argv[1]) output_path = Path(sys.argv[2]) lines = raw_path.read_text(encoding="utf-8").splitlines() -exclude = {"pygobject", "python-xlib"} filtered = [] for line in lines: stripped = line.strip() if not stripped or stripped == ".": continue - match = re.match(r"([A-Za-z0-9_.-]+)", stripped) - if match and match.group(1).lower().replace("_", "-") in exclude: - continue filtered.append(line) output_path.write_text("\n".join(filtered) + "\n", encoding="utf-8") raw_path.unlink() diff --git a/src/aman_run.py b/src/aman_run.py index 2e5dc48..062fb51 100644 --- a/src/aman_run.py +++ b/src/aman_run.py @@ -8,7 +8,14 @@ import signal import threading from pathlib import Path -from config import Config, ConfigValidationError, load, redacted_dict, save, validate +from config import ( + Config, + ConfigValidationError, + config_log_payload, + load, + save, + validate, +) from constants import DEFAULT_CONFIG_PATH, MODEL_PATH from desktop import get_desktop_adapter from diagnostics import ( @@ -232,7 +239,7 @@ def run_command(args) -> int: logging.info( "config (%s):\n%s", str(config_path), - json.dumps(redacted_dict(cfg), indent=2), + json.dumps(config_log_payload(cfg), indent=2), ) if not config_existed_before_start: logging.info("first launch settings completed") diff --git a/src/config.py b/src/config.py index 77491bd..3be995c 100644 --- a/src/config.py +++ b/src/config.py @@ -152,13 +152,35 @@ def save(path: str | Path | None, cfg: Config) -> Path: return target -def redacted_dict(cfg: Config) -> dict[str, Any]: +def config_as_dict(cfg: Config) -> dict[str, Any]: return asdict(cfg) +def config_log_payload(cfg: Config) -> dict[str, Any]: + return { + "daemon_hotkey": cfg.daemon.hotkey, + "recording_input": cfg.recording.input, + "stt_provider": cfg.stt.provider, + "stt_model": cfg.stt.model, + "stt_device": cfg.stt.device, + "stt_language": cfg.stt.language, + "custom_whisper_path_configured": bool( + cfg.models.whisper_model_path.strip() + ), + "injection_backend": cfg.injection.backend, + "remove_transcription_from_clipboard": ( + cfg.injection.remove_transcription_from_clipboard + ), + "safety_enabled": cfg.safety.enabled, + "safety_strict": cfg.safety.strict, + "ux_profile": cfg.ux.profile, + "strict_startup": cfg.advanced.strict_startup, + } + + def _write_default_config(path: Path, cfg: Config) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f"{json.dumps(redacted_dict(cfg), indent=2)}\n", encoding="utf-8") + path.write_text(f"{json.dumps(config_as_dict(cfg), indent=2)}\n", encoding="utf-8") def validate(cfg: Config) -> None: diff --git a/src/config_ui.py b/src/config_ui.py index dcc6c39..54eaca8 100644 --- a/src/config_ui.py +++ b/src/config_ui.py @@ -3,29 +3,34 @@ from __future__ import annotations import copy import importlib.metadata import logging -import time from dataclasses import dataclass from pathlib import Path import gi -from config import ( - Config, - DEFAULT_STT_PROVIDER, +from config import Config, DEFAULT_STT_PROVIDER +from config_ui_audio import AudioSettingsService +from config_ui_pages import ( + build_about_page, + build_advanced_page, + build_audio_page, + build_general_page, + build_help_page, +) +from config_ui_runtime import ( + RUNTIME_MODE_EXPERT, + RUNTIME_MODE_MANAGED, + apply_canonical_runtime_defaults, + infer_runtime_mode, ) from constants import DEFAULT_CONFIG_PATH -from languages import COMMON_STT_LANGUAGE_OPTIONS, stt_language_label -from recorder import list_input_devices, resolve_input_device, start_recording, stop_recording +from languages import stt_language_label gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gtk # type: ignore[import-not-found] -RUNTIME_MODE_MANAGED = "aman_managed" -RUNTIME_MODE_EXPERT = "expert_custom" - - @dataclass class ConfigUiResult: saved: bool @@ -33,21 +38,6 @@ class ConfigUiResult: closed_reason: str | None = None -def infer_runtime_mode(cfg: Config) -> str: - is_canonical = ( - cfg.stt.provider.strip().lower() == DEFAULT_STT_PROVIDER - and not bool(cfg.models.allow_custom_models) - and not cfg.models.whisper_model_path.strip() - ) - return RUNTIME_MODE_MANAGED if is_canonical else RUNTIME_MODE_EXPERT - - -def apply_canonical_runtime_defaults(cfg: Config) -> None: - cfg.stt.provider = DEFAULT_STT_PROVIDER - cfg.models.allow_custom_models = False - cfg.models.whisper_model_path = "" - - class ConfigWindow: def __init__( self, @@ -61,7 +51,8 @@ class ConfigWindow: self._config = copy.deepcopy(initial_cfg) self._required = required self._config_path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH - self._devices = list_input_devices() + self._audio_settings = AudioSettingsService() + self._devices = self._audio_settings.list_input_devices() self._device_by_id = {str(device["index"]): device for device in self._devices} self._row_to_section: dict[Gtk.ListBoxRow, str] = {} self._runtime_mode = infer_runtime_mode(self._config) @@ -115,11 +106,11 @@ class ConfigWindow: self._stack.set_transition_duration(120) body.pack_start(self._stack, True, True, 0) - self._general_page = self._build_general_page() - self._audio_page = self._build_audio_page() - self._advanced_page = self._build_advanced_page() - self._help_page = self._build_help_page() - self._about_page = self._build_about_page() + self._general_page = build_general_page(self) + self._audio_page = build_audio_page(self) + self._advanced_page = build_advanced_page(self) + self._help_page = build_help_page(self, present_about_dialog=_present_about_dialog) + self._about_page = build_about_page(self, present_about_dialog=_present_about_dialog) self._add_section("general", "General", self._general_page) self._add_section("audio", "Audio", self._audio_page) @@ -169,261 +160,6 @@ class ConfigWindow: if section: self._stack.set_visible_child_name(section) - def _build_general_page(self) -> Gtk.Widget: - grid = Gtk.Grid(column_spacing=12, row_spacing=10) - grid.set_margin_start(14) - grid.set_margin_end(14) - grid.set_margin_top(14) - grid.set_margin_bottom(14) - - hotkey_label = Gtk.Label(label="Trigger hotkey") - hotkey_label.set_xalign(0.0) - self._hotkey_entry = Gtk.Entry() - self._hotkey_entry.set_placeholder_text("Super+m") - self._hotkey_entry.connect("changed", lambda *_: self._validate_hotkey()) - grid.attach(hotkey_label, 0, 0, 1, 1) - grid.attach(self._hotkey_entry, 1, 0, 1, 1) - - self._hotkey_error = Gtk.Label(label="") - self._hotkey_error.set_xalign(0.0) - self._hotkey_error.set_line_wrap(True) - grid.attach(self._hotkey_error, 1, 1, 1, 1) - - backend_label = Gtk.Label(label="Text injection") - backend_label.set_xalign(0.0) - self._backend_combo = Gtk.ComboBoxText() - self._backend_combo.append("clipboard", "Clipboard paste (recommended)") - self._backend_combo.append("injection", "Simulated typing") - grid.attach(backend_label, 0, 2, 1, 1) - grid.attach(self._backend_combo, 1, 2, 1, 1) - - self._remove_clipboard_check = Gtk.CheckButton( - label="Remove transcription from clipboard after paste" - ) - self._remove_clipboard_check.set_hexpand(True) - grid.attach(self._remove_clipboard_check, 1, 3, 1, 1) - - language_label = Gtk.Label(label="Transcription language") - language_label.set_xalign(0.0) - self._language_combo = Gtk.ComboBoxText() - for code, label in COMMON_STT_LANGUAGE_OPTIONS: - self._language_combo.append(code, label) - grid.attach(language_label, 0, 4, 1, 1) - grid.attach(self._language_combo, 1, 4, 1, 1) - - profile_label = Gtk.Label(label="Profile") - profile_label.set_xalign(0.0) - self._profile_combo = Gtk.ComboBoxText() - self._profile_combo.append("default", "Default") - self._profile_combo.append("fast", "Fast (lower latency)") - self._profile_combo.append("polished", "Polished") - grid.attach(profile_label, 0, 5, 1, 1) - grid.attach(self._profile_combo, 1, 5, 1, 1) - - return grid - - def _build_audio_page(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - box.set_margin_start(14) - box.set_margin_end(14) - box.set_margin_top(14) - box.set_margin_bottom(14) - - input_label = Gtk.Label(label="Input device") - input_label.set_xalign(0.0) - box.pack_start(input_label, False, False, 0) - - self._mic_combo = Gtk.ComboBoxText() - self._mic_combo.append("", "System default") - for device in self._devices: - self._mic_combo.append(str(device["index"]), f"{device['index']}: {device['name']}") - box.pack_start(self._mic_combo, False, False, 0) - - test_button = Gtk.Button(label="Test microphone") - test_button.connect("clicked", lambda *_: self._on_test_microphone()) - box.pack_start(test_button, False, False, 0) - - self._mic_status = Gtk.Label(label="") - self._mic_status.set_xalign(0.0) - self._mic_status.set_line_wrap(True) - box.pack_start(self._mic_status, False, False, 0) - return box - - def _build_advanced_page(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - box.set_margin_start(14) - box.set_margin_end(14) - box.set_margin_top(14) - box.set_margin_bottom(14) - - self._strict_startup_check = Gtk.CheckButton(label="Fail fast on startup validation errors") - box.pack_start(self._strict_startup_check, False, False, 0) - - safety_title = Gtk.Label() - safety_title.set_markup("Output safety") - safety_title.set_xalign(0.0) - box.pack_start(safety_title, False, False, 0) - - self._safety_enabled_check = Gtk.CheckButton( - label="Enable fact-preservation guard (recommended)" - ) - self._safety_enabled_check.connect("toggled", lambda *_: self._on_safety_guard_toggled()) - box.pack_start(self._safety_enabled_check, False, False, 0) - - self._safety_strict_check = Gtk.CheckButton( - label="Strict mode: reject output when facts are changed" - ) - box.pack_start(self._safety_strict_check, False, False, 0) - - runtime_title = Gtk.Label() - runtime_title.set_markup("Runtime management") - runtime_title.set_xalign(0.0) - box.pack_start(runtime_title, False, False, 0) - - runtime_copy = Gtk.Label( - label=( - "Aman-managed mode handles the canonical editor model lifecycle for you. " - "Expert mode keeps Aman open-source friendly by letting you use custom Whisper paths." - ) - ) - runtime_copy.set_xalign(0.0) - runtime_copy.set_line_wrap(True) - box.pack_start(runtime_copy, False, False, 0) - - mode_label = Gtk.Label(label="Runtime mode") - mode_label.set_xalign(0.0) - box.pack_start(mode_label, False, False, 0) - - self._runtime_mode_combo = Gtk.ComboBoxText() - self._runtime_mode_combo.append(RUNTIME_MODE_MANAGED, "Aman-managed (recommended)") - self._runtime_mode_combo.append(RUNTIME_MODE_EXPERT, "Expert mode (custom Whisper path)") - self._runtime_mode_combo.connect("changed", lambda *_: self._on_runtime_mode_changed(user_initiated=True)) - box.pack_start(self._runtime_mode_combo, False, False, 0) - - self._runtime_status_label = Gtk.Label(label="") - self._runtime_status_label.set_xalign(0.0) - self._runtime_status_label.set_line_wrap(True) - box.pack_start(self._runtime_status_label, False, False, 0) - - self._expert_expander = Gtk.Expander(label="Expert options") - self._expert_expander.set_expanded(False) - box.pack_start(self._expert_expander, False, False, 0) - - expert_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - expert_box.set_margin_start(10) - expert_box.set_margin_end(10) - expert_box.set_margin_top(8) - expert_box.set_margin_bottom(8) - self._expert_expander.add(expert_box) - - expert_warning = Gtk.InfoBar() - expert_warning.set_show_close_button(False) - expert_warning.set_message_type(Gtk.MessageType.WARNING) - warning_label = Gtk.Label( - label=( - "Expert mode is best-effort and may require manual troubleshooting. " - "Aman-managed mode is the canonical supported path." - ) - ) - warning_label.set_xalign(0.0) - warning_label.set_line_wrap(True) - expert_warning.get_content_area().pack_start(warning_label, True, True, 0) - expert_box.pack_start(expert_warning, False, False, 0) - - self._allow_custom_models_check = Gtk.CheckButton( - label="Allow custom local model paths" - ) - self._allow_custom_models_check.connect("toggled", lambda *_: self._on_runtime_widgets_changed()) - expert_box.pack_start(self._allow_custom_models_check, False, False, 0) - - whisper_model_path_label = Gtk.Label(label="Custom Whisper model path") - whisper_model_path_label.set_xalign(0.0) - expert_box.pack_start(whisper_model_path_label, False, False, 0) - self._whisper_model_path_entry = Gtk.Entry() - self._whisper_model_path_entry.connect("changed", lambda *_: self._on_runtime_widgets_changed()) - expert_box.pack_start(self._whisper_model_path_entry, False, False, 0) - - self._runtime_error = Gtk.Label(label="") - self._runtime_error.set_xalign(0.0) - self._runtime_error.set_line_wrap(True) - expert_box.pack_start(self._runtime_error, False, False, 0) - - path_label = Gtk.Label(label="Config path") - path_label.set_xalign(0.0) - box.pack_start(path_label, False, False, 0) - - path_entry = Gtk.Entry() - path_entry.set_editable(False) - path_entry.set_text(str(self._config_path)) - box.pack_start(path_entry, False, False, 0) - - note = Gtk.Label( - label=( - "Tip: after editing the file directly, use Reload Config from the tray to apply changes." - ) - ) - note.set_xalign(0.0) - note.set_line_wrap(True) - box.pack_start(note, False, False, 0) - return box - - def _build_help_page(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - box.set_margin_start(14) - box.set_margin_end(14) - box.set_margin_top(14) - box.set_margin_bottom(14) - - help_text = Gtk.Label( - label=( - "Usage:\n" - "- Press your hotkey to start recording.\n" - "- Press the hotkey again to stop and process.\n" - "- Press Esc while recording to cancel.\n\n" - "Supported path:\n" - "- Daily use runs through the tray and user service.\n" - "- Aman-managed mode (recommended) handles model lifecycle for you.\n" - "- Expert mode keeps custom Whisper paths available for advanced users.\n\n" - "Recovery:\n" - "- Use Run Diagnostics from the tray for a deeper self-check.\n" - "- If that is not enough, run aman doctor, then aman self-check.\n" - "- Next escalations are journalctl --user -u aman and aman run --verbose.\n\n" - "Safety tips:\n" - "- Keep fact guard enabled to prevent accidental name/number changes.\n" - "- Strict safety blocks output on fact violations." - ) - ) - help_text.set_xalign(0.0) - help_text.set_line_wrap(True) - box.pack_start(help_text, False, False, 0) - - about_button = Gtk.Button(label="Open About Dialog") - about_button.connect("clicked", lambda *_: _present_about_dialog(self._dialog)) - box.pack_start(about_button, False, False, 0) - return box - - def _build_about_page(self) -> Gtk.Widget: - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - box.set_margin_start(14) - box.set_margin_end(14) - box.set_margin_top(14) - box.set_margin_bottom(14) - - title = Gtk.Label() - title.set_markup("Aman") - title.set_xalign(0.0) - box.pack_start(title, False, False, 0) - - subtitle = Gtk.Label(label="Local amanuensis for X11 desktop dictation and rewriting.") - subtitle.set_xalign(0.0) - subtitle.set_line_wrap(True) - box.pack_start(subtitle, False, False, 0) - - about_button = Gtk.Button(label="About Aman") - about_button.connect("clicked", lambda *_: _present_about_dialog(self._dialog)) - box.pack_start(about_button, False, False, 0) - return box - def _initialize_widget_values(self) -> None: hotkey = self._config.daemon.hotkey.strip() or "Super+m" self._hotkey_entry.set_text(hotkey) @@ -457,7 +193,7 @@ class ConfigWindow: self._sync_runtime_mode_ui(user_initiated=False) self._validate_runtime_settings() - resolved = resolve_input_device(self._config.recording.input) + resolved = self._audio_settings.resolve_input_device(self._config.recording.input) if resolved is None: self._mic_combo.set_active_id("") return @@ -536,16 +272,8 @@ class ConfigWindow: self._mic_status.set_text("Testing microphone...") while Gtk.events_pending(): Gtk.main_iteration() - try: - stream, record = start_recording(input_spec) - time.sleep(0.35) - audio = stop_recording(stream, record) - if getattr(audio, "size", 0) > 0: - self._mic_status.set_text("Microphone test successful.") - return - self._mic_status.set_text("No audio captured. Try another device.") - except Exception as exc: - self._mic_status.set_text(f"Microphone test failed: {exc}") + result = self._audio_settings.test_microphone(input_spec) + self._mic_status.set_text(result.message) def _validate_hotkey(self) -> bool: hotkey = self._hotkey_entry.get_text().strip() diff --git a/src/config_ui_audio.py b/src/config_ui_audio.py new file mode 100644 index 0000000..e2e8a53 --- /dev/null +++ b/src/config_ui_audio.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +from recorder import ( + list_input_devices, + resolve_input_device, + start_recording, + stop_recording, +) + + +@dataclass(frozen=True) +class MicrophoneTestResult: + ok: bool + message: str + + +class AudioSettingsService: + def list_input_devices(self) -> list[dict[str, Any]]: + return list_input_devices() + + def resolve_input_device(self, input_spec: str | int | None) -> int | None: + return resolve_input_device(input_spec) + + def test_microphone( + self, + input_spec: str | int | None, + *, + duration_sec: float = 0.35, + ) -> MicrophoneTestResult: + try: + stream, record = start_recording(input_spec) + time.sleep(duration_sec) + audio = stop_recording(stream, record) + except Exception as exc: + return MicrophoneTestResult( + ok=False, + message=f"Microphone test failed: {exc}", + ) + + if getattr(audio, "size", 0) > 0: + return MicrophoneTestResult( + ok=True, + message="Microphone test successful.", + ) + return MicrophoneTestResult( + ok=False, + message="No audio captured. Try another device.", + ) diff --git a/src/config_ui_pages.py b/src/config_ui_pages.py new file mode 100644 index 0000000..714ab37 --- /dev/null +++ b/src/config_ui_pages.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import gi + +from config_ui_runtime import RUNTIME_MODE_EXPERT, RUNTIME_MODE_MANAGED +from languages import COMMON_STT_LANGUAGE_OPTIONS + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # type: ignore[import-not-found] + + +def _page_box() -> Gtk.Box: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_margin_start(14) + box.set_margin_end(14) + box.set_margin_top(14) + box.set_margin_bottom(14) + return box + + +def build_general_page(window) -> Gtk.Widget: + grid = Gtk.Grid(column_spacing=12, row_spacing=10) + grid.set_margin_start(14) + grid.set_margin_end(14) + grid.set_margin_top(14) + grid.set_margin_bottom(14) + + hotkey_label = Gtk.Label(label="Trigger hotkey") + hotkey_label.set_xalign(0.0) + window._hotkey_entry = Gtk.Entry() + window._hotkey_entry.set_placeholder_text("Super+m") + window._hotkey_entry.connect("changed", lambda *_: window._validate_hotkey()) + grid.attach(hotkey_label, 0, 0, 1, 1) + grid.attach(window._hotkey_entry, 1, 0, 1, 1) + + window._hotkey_error = Gtk.Label(label="") + window._hotkey_error.set_xalign(0.0) + window._hotkey_error.set_line_wrap(True) + grid.attach(window._hotkey_error, 1, 1, 1, 1) + + backend_label = Gtk.Label(label="Text injection") + backend_label.set_xalign(0.0) + window._backend_combo = Gtk.ComboBoxText() + window._backend_combo.append("clipboard", "Clipboard paste (recommended)") + window._backend_combo.append("injection", "Simulated typing") + grid.attach(backend_label, 0, 2, 1, 1) + grid.attach(window._backend_combo, 1, 2, 1, 1) + + window._remove_clipboard_check = Gtk.CheckButton( + label="Remove transcription from clipboard after paste" + ) + window._remove_clipboard_check.set_hexpand(True) + grid.attach(window._remove_clipboard_check, 1, 3, 1, 1) + + language_label = Gtk.Label(label="Transcription language") + language_label.set_xalign(0.0) + window._language_combo = Gtk.ComboBoxText() + for code, label in COMMON_STT_LANGUAGE_OPTIONS: + window._language_combo.append(code, label) + grid.attach(language_label, 0, 4, 1, 1) + grid.attach(window._language_combo, 1, 4, 1, 1) + + profile_label = Gtk.Label(label="Profile") + profile_label.set_xalign(0.0) + window._profile_combo = Gtk.ComboBoxText() + window._profile_combo.append("default", "Default") + window._profile_combo.append("fast", "Fast (lower latency)") + window._profile_combo.append("polished", "Polished") + grid.attach(profile_label, 0, 5, 1, 1) + grid.attach(window._profile_combo, 1, 5, 1, 1) + + return grid + + +def build_audio_page(window) -> Gtk.Widget: + box = _page_box() + + input_label = Gtk.Label(label="Input device") + input_label.set_xalign(0.0) + box.pack_start(input_label, False, False, 0) + + window._mic_combo = Gtk.ComboBoxText() + window._mic_combo.append("", "System default") + for device in window._devices: + window._mic_combo.append( + str(device["index"]), + f"{device['index']}: {device['name']}", + ) + box.pack_start(window._mic_combo, False, False, 0) + + test_button = Gtk.Button(label="Test microphone") + test_button.connect("clicked", lambda *_: window._on_test_microphone()) + box.pack_start(test_button, False, False, 0) + + window._mic_status = Gtk.Label(label="") + window._mic_status.set_xalign(0.0) + window._mic_status.set_line_wrap(True) + box.pack_start(window._mic_status, False, False, 0) + return box + + +def build_advanced_page(window) -> Gtk.Widget: + box = _page_box() + + window._strict_startup_check = Gtk.CheckButton( + label="Fail fast on startup validation errors" + ) + box.pack_start(window._strict_startup_check, False, False, 0) + + safety_title = Gtk.Label() + safety_title.set_markup("Output safety") + safety_title.set_xalign(0.0) + box.pack_start(safety_title, False, False, 0) + + window._safety_enabled_check = Gtk.CheckButton( + label="Enable fact-preservation guard (recommended)" + ) + window._safety_enabled_check.connect( + "toggled", + lambda *_: window._on_safety_guard_toggled(), + ) + box.pack_start(window._safety_enabled_check, False, False, 0) + + window._safety_strict_check = Gtk.CheckButton( + label="Strict mode: reject output when facts are changed" + ) + box.pack_start(window._safety_strict_check, False, False, 0) + + runtime_title = Gtk.Label() + runtime_title.set_markup("Runtime management") + runtime_title.set_xalign(0.0) + box.pack_start(runtime_title, False, False, 0) + + runtime_copy = Gtk.Label( + label=( + "Aman-managed mode handles the canonical editor model lifecycle for you. " + "Expert mode keeps Aman open-source friendly by letting you use custom Whisper paths." + ) + ) + runtime_copy.set_xalign(0.0) + runtime_copy.set_line_wrap(True) + box.pack_start(runtime_copy, False, False, 0) + + mode_label = Gtk.Label(label="Runtime mode") + mode_label.set_xalign(0.0) + box.pack_start(mode_label, False, False, 0) + + window._runtime_mode_combo = Gtk.ComboBoxText() + window._runtime_mode_combo.append( + RUNTIME_MODE_MANAGED, + "Aman-managed (recommended)", + ) + window._runtime_mode_combo.append( + RUNTIME_MODE_EXPERT, + "Expert mode (custom Whisper path)", + ) + window._runtime_mode_combo.connect( + "changed", + lambda *_: window._on_runtime_mode_changed(user_initiated=True), + ) + box.pack_start(window._runtime_mode_combo, False, False, 0) + + window._runtime_status_label = Gtk.Label(label="") + window._runtime_status_label.set_xalign(0.0) + window._runtime_status_label.set_line_wrap(True) + box.pack_start(window._runtime_status_label, False, False, 0) + + window._expert_expander = Gtk.Expander(label="Expert options") + window._expert_expander.set_expanded(False) + box.pack_start(window._expert_expander, False, False, 0) + + expert_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + expert_box.set_margin_start(10) + expert_box.set_margin_end(10) + expert_box.set_margin_top(8) + expert_box.set_margin_bottom(8) + window._expert_expander.add(expert_box) + + expert_warning = Gtk.InfoBar() + expert_warning.set_show_close_button(False) + expert_warning.set_message_type(Gtk.MessageType.WARNING) + warning_label = Gtk.Label( + label=( + "Expert mode is best-effort and may require manual troubleshooting. " + "Aman-managed mode is the canonical supported path." + ) + ) + warning_label.set_xalign(0.0) + warning_label.set_line_wrap(True) + expert_warning.get_content_area().pack_start(warning_label, True, True, 0) + expert_box.pack_start(expert_warning, False, False, 0) + + window._allow_custom_models_check = Gtk.CheckButton( + label="Allow custom local model paths" + ) + window._allow_custom_models_check.connect( + "toggled", + lambda *_: window._on_runtime_widgets_changed(), + ) + expert_box.pack_start(window._allow_custom_models_check, False, False, 0) + + whisper_model_path_label = Gtk.Label(label="Custom Whisper model path") + whisper_model_path_label.set_xalign(0.0) + expert_box.pack_start(whisper_model_path_label, False, False, 0) + window._whisper_model_path_entry = Gtk.Entry() + window._whisper_model_path_entry.connect( + "changed", + lambda *_: window._on_runtime_widgets_changed(), + ) + expert_box.pack_start(window._whisper_model_path_entry, False, False, 0) + + window._runtime_error = Gtk.Label(label="") + window._runtime_error.set_xalign(0.0) + window._runtime_error.set_line_wrap(True) + expert_box.pack_start(window._runtime_error, False, False, 0) + + path_label = Gtk.Label(label="Config path") + path_label.set_xalign(0.0) + box.pack_start(path_label, False, False, 0) + + path_entry = Gtk.Entry() + path_entry.set_editable(False) + path_entry.set_text(str(window._config_path)) + box.pack_start(path_entry, False, False, 0) + + note = Gtk.Label( + label=( + "Tip: after editing the file directly, use Reload Config from the tray to apply changes." + ) + ) + note.set_xalign(0.0) + note.set_line_wrap(True) + box.pack_start(note, False, False, 0) + return box + + +def build_help_page(window, *, present_about_dialog) -> Gtk.Widget: + box = _page_box() + + help_text = Gtk.Label( + label=( + "Usage:\n" + "- Press your hotkey to start recording.\n" + "- Press the hotkey again to stop and process.\n" + "- Press Esc while recording to cancel.\n\n" + "Supported path:\n" + "- Daily use runs through the tray and user service.\n" + "- Aman-managed mode (recommended) handles model lifecycle for you.\n" + "- Expert mode keeps custom Whisper paths available for advanced users.\n\n" + "Recovery:\n" + "- Use Run Diagnostics from the tray for a deeper self-check.\n" + "- If that is not enough, run aman doctor, then aman self-check.\n" + "- Next escalations are journalctl --user -u aman and aman run --verbose.\n\n" + "Safety tips:\n" + "- Keep fact guard enabled to prevent accidental name/number changes.\n" + "- Strict safety blocks output on fact violations." + ) + ) + help_text.set_xalign(0.0) + help_text.set_line_wrap(True) + box.pack_start(help_text, False, False, 0) + + about_button = Gtk.Button(label="Open About Dialog") + about_button.connect( + "clicked", + lambda *_: present_about_dialog(window._dialog), + ) + box.pack_start(about_button, False, False, 0) + return box + + +def build_about_page(window, *, present_about_dialog) -> Gtk.Widget: + box = _page_box() + + title = Gtk.Label() + title.set_markup("Aman") + title.set_xalign(0.0) + box.pack_start(title, False, False, 0) + + subtitle = Gtk.Label( + label="Local amanuensis for X11 desktop dictation and rewriting." + ) + subtitle.set_xalign(0.0) + subtitle.set_line_wrap(True) + box.pack_start(subtitle, False, False, 0) + + about_button = Gtk.Button(label="About Aman") + about_button.connect( + "clicked", + lambda *_: present_about_dialog(window._dialog), + ) + box.pack_start(about_button, False, False, 0) + return box diff --git a/src/config_ui_runtime.py b/src/config_ui_runtime.py new file mode 100644 index 0000000..e20c65e --- /dev/null +++ b/src/config_ui_runtime.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from config import Config, DEFAULT_STT_PROVIDER + + +RUNTIME_MODE_MANAGED = "aman_managed" +RUNTIME_MODE_EXPERT = "expert_custom" + + +def infer_runtime_mode(cfg: Config) -> str: + is_canonical = ( + cfg.stt.provider.strip().lower() == DEFAULT_STT_PROVIDER + and not bool(cfg.models.allow_custom_models) + and not cfg.models.whisper_model_path.strip() + ) + return RUNTIME_MODE_MANAGED if is_canonical else RUNTIME_MODE_EXPERT + + +def apply_canonical_runtime_defaults(cfg: Config) -> None: + cfg.stt.provider = DEFAULT_STT_PROVIDER + cfg.models.allow_custom_models = False + cfg.models.whisper_model_path = "" diff --git a/src/diagnostics.py b/src/diagnostics.py index 162ee3e..2bd51a7 100644 --- a/src/diagnostics.py +++ b/src/diagnostics.py @@ -153,10 +153,6 @@ def run_self_check(config_path: str | None) -> DiagnosticReport: return DiagnosticReport(checks=checks) -def run_diagnostics(config_path: str | None) -> DiagnosticReport: - return run_doctor(config_path) - - def _resolved_config_path(config_path: str | Path | None) -> Path: if config_path: return Path(config_path) diff --git a/src/recorder.py b/src/recorder.py index 1dd26c6..bfd380f 100644 --- a/src/recorder.py +++ b/src/recorder.py @@ -22,16 +22,6 @@ def list_input_devices() -> list[dict]: return devices -def default_input_device() -> int | None: - sd = _sounddevice() - default = sd.default.device - if isinstance(default, (tuple, list)) and default: - return default[0] - if isinstance(default, int): - return default - return None - - def resolve_input_device(spec: str | int | None) -> int | None: if spec is None: return None diff --git a/tests/test_aman_run.py b/tests/test_aman_run.py index 1539ba5..fb3321d 100644 --- a/tests/test_aman_run.py +++ b/tests/test_aman_run.py @@ -205,6 +205,33 @@ class AmanRunTests(unittest.TestCase): self.assertIn("startup.readiness: startup failed: warmup boom", rendered) self.assertIn("next_step: run `aman self-check --config", rendered) + def test_run_command_logs_safe_config_payload(self): + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.json" + path.write_text(json.dumps({"config_version": 1}) + "\n", encoding="utf-8") + custom_model_path = Path(td) / "custom-whisper.bin" + custom_model_path.write_text("model\n", encoding="utf-8") + args = aman_cli.parse_cli_args(["run", "--config", str(path)]) + desktop = _FakeDesktop() + cfg = Config() + cfg.recording.input = "USB Mic" + cfg.models.allow_custom_models = True + cfg.models.whisper_model_path = str(custom_model_path) + cfg.vocabulary.terms = ["SensitiveTerm"] + with patch("aman_run.lock_single_instance", return_value=object()), patch( + "aman_run.get_desktop_adapter", return_value=desktop + ), patch("aman_run.load_runtime_config", return_value=cfg), patch( + "aman_run.Daemon", _FakeDaemon + ), self.assertLogs(level="INFO") as logs: + exit_code = aman_run.run_command(args) + + self.assertEqual(exit_code, 0) + rendered = "\n".join(logs.output) + self.assertIn('"custom_whisper_path_configured": true', rendered) + self.assertIn('"recording_input": "USB Mic"', rendered) + self.assertNotIn(str(custom_model_path), rendered) + self.assertNotIn("SensitiveTerm", rendered) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index fd5d676..64f2d2c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,7 +9,7 @@ SRC = ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) -from config import CURRENT_CONFIG_VERSION, load, redacted_dict +from config import CURRENT_CONFIG_VERSION, Config, config_as_dict, config_log_payload, load class ConfigTests(unittest.TestCase): @@ -39,7 +39,7 @@ class ConfigTests(unittest.TestCase): self.assertTrue(missing.exists()) written = json.loads(missing.read_text(encoding="utf-8")) - self.assertEqual(written, redacted_dict(cfg)) + self.assertEqual(written, config_as_dict(cfg)) def test_loads_nested_config(self): payload = { @@ -311,6 +311,18 @@ class ConfigTests(unittest.TestCase): ): load(str(path)) + def test_config_log_payload_omits_vocabulary_and_custom_model_path(self): + cfg = Config() + cfg.models.allow_custom_models = True + cfg.models.whisper_model_path = "/tmp/custom-whisper.bin" + cfg.vocabulary.terms = ["SensitiveTerm"] + + payload = config_log_payload(cfg) + + self.assertTrue(payload["custom_whisper_path_configured"]) + self.assertNotIn("vocabulary", payload) + self.assertNotIn("whisper_model_path", payload) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config_ui_audio.py b/tests/test_config_ui_audio.py new file mode 100644 index 0000000..7ccc502 --- /dev/null +++ b/tests/test_config_ui_audio.py @@ -0,0 +1,53 @@ +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from config_ui_audio import AudioSettingsService + + +class AudioSettingsServiceTests(unittest.TestCase): + def test_microphone_test_reports_success_when_audio_is_captured(self): + service = AudioSettingsService() + with patch("config_ui_audio.start_recording", return_value=("stream", "record")), patch( + "config_ui_audio.stop_recording", + return_value=SimpleNamespace(size=4), + ), patch("config_ui_audio.time.sleep") as sleep_mock: + result = service.test_microphone("USB Mic", duration_sec=0.0) + + self.assertTrue(result.ok) + self.assertEqual(result.message, "Microphone test successful.") + sleep_mock.assert_called_once_with(0.0) + + def test_microphone_test_reports_empty_capture(self): + service = AudioSettingsService() + with patch("config_ui_audio.start_recording", return_value=("stream", "record")), patch( + "config_ui_audio.stop_recording", + return_value=SimpleNamespace(size=0), + ), patch("config_ui_audio.time.sleep"): + result = service.test_microphone("USB Mic", duration_sec=0.0) + + self.assertFalse(result.ok) + self.assertEqual(result.message, "No audio captured. Try another device.") + + def test_microphone_test_surfaces_recording_errors(self): + service = AudioSettingsService() + with patch( + "config_ui_audio.start_recording", + side_effect=RuntimeError("device missing"), + ), patch("config_ui_audio.time.sleep") as sleep_mock: + result = service.test_microphone("USB Mic", duration_sec=0.0) + + self.assertFalse(result.ok) + self.assertEqual(result.message, "Microphone test failed: device missing") + sleep_mock.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index cce1984..ceb8cbb 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -16,7 +16,6 @@ from diagnostics import ( DiagnosticCheck, DiagnosticReport, run_doctor, - run_diagnostics, run_self_check, ) @@ -192,26 +191,6 @@ class DiagnosticsTests(unittest.TestCase): self.assertIn("networked connection", results["model.cache"].next_step) probe_model.assert_called_once() - def test_run_diagnostics_alias_matches_doctor(self): - cfg = Config() - with tempfile.TemporaryDirectory() as td: - config_path = Path(td) / "config.json" - config_path.write_text('{"config_version":1}\n', encoding="utf-8") - with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=False), patch( - "diagnostics.load_existing", return_value=cfg - ), patch("diagnostics.list_input_devices", return_value=[{"index": 1, "name": "Mic"}]), patch( - "diagnostics.resolve_input_device", return_value=1 - ), patch( - "diagnostics.get_desktop_adapter", return_value=_FakeDesktop() - ), patch( - "diagnostics._run_systemctl_user", - return_value=_Result(returncode=0, stdout="running\n"), - ): - report = run_diagnostics(str(config_path)) - - self.assertEqual(report.status, "ok") - self.assertEqual(len(report.checks), 7) - def test_report_json_schema_includes_status_and_next_step(self): report = DiagnosticReport( checks=[ diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 0000000..ae474de --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -0,0 +1,55 @@ +import ast +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _parse_toml_string_array(text: str, key: str) -> list[str]: + match = re.search(rf"(?ms)^\s*{re.escape(key)}\s*=\s*\[(.*?)^\s*\]", text) + if not match: + raise AssertionError(f"{key} array not found") + return ast.literal_eval("[" + match.group(1) + "]") + + +class PackagingMetadataTests(unittest.TestCase): + def test_py_modules_matches_top_level_src_modules(self): + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + py_modules = sorted(_parse_toml_string_array(text, "py-modules")) + discovered = sorted(path.stem for path in (ROOT / "src").glob("*.py")) + self.assertEqual(py_modules, discovered) + + def test_project_dependencies_exclude_native_gui_bindings(self): + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + dependencies = _parse_toml_string_array(text, "dependencies") + self.assertNotIn("PyGObject", dependencies) + self.assertNotIn("python-xlib", dependencies) + + def test_runtime_requirements_follow_project_dependency_contract(self): + with tempfile.TemporaryDirectory() as td: + output_path = Path(td) / "requirements.txt" + script = ( + f'source "{ROOT / "scripts" / "package_common.sh"}"\n' + f'write_runtime_requirements "{output_path}"\n' + ) + subprocess.run( + ["bash", "-lc", script], + cwd=ROOT, + text=True, + capture_output=True, + check=True, + ) + + requirements = output_path.read_text(encoding="utf-8").splitlines() + + self.assertIn("faster-whisper", requirements) + self.assertIn("llama-cpp-python", requirements) + self.assertNotIn("PyGObject", requirements) + self.assertNotIn("python-xlib", requirements) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_portable_bundle.py b/tests/test_portable_bundle.py index e366400..56c0c24 100644 --- a/tests/test_portable_bundle.py +++ b/tests/test_portable_bundle.py @@ -208,15 +208,22 @@ class PortableBundleTests(unittest.TestCase): self.assertTrue(tarball.exists()) self.assertTrue(checksum.exists()) self.assertTrue(wheel_path.exists()) + prefix = f"aman-x11-linux-{version}" with zipfile.ZipFile(wheel_path) as archive: wheel_names = set(archive.namelist()) metadata_path = f"aman-{version}.dist-info/METADATA" metadata = archive.read(metadata_path).decode("utf-8") self.assertNotIn("desktop_wayland.py", wheel_names) self.assertNotIn("Requires-Dist: pillow", metadata) + self.assertNotIn("Requires-Dist: PyGObject", metadata) + self.assertNotIn("Requires-Dist: python-xlib", metadata) with tarfile.open(tarball, "r:gz") as archive: names = set(archive.getnames()) - prefix = f"aman-x11-linux-{version}" + requirements_path = f"{prefix}/requirements/cp311.txt" + requirements_member = archive.extractfile(requirements_path) + if requirements_member is None: + self.fail(f"missing {requirements_path} in portable archive") + requirements_text = requirements_member.read().decode("utf-8") self.assertIn(f"{prefix}/install.sh", names) self.assertIn(f"{prefix}/uninstall.sh", names) self.assertIn(f"{prefix}/portable_installer.py", names) @@ -229,6 +236,8 @@ class PortableBundleTests(unittest.TestCase): self.assertIn(f"{prefix}/requirements/cp311.txt", names) self.assertIn(f"{prefix}/requirements/cp312.txt", names) self.assertIn(f"{prefix}/systemd/aman.service.in", names) + self.assertNotIn("pygobject", requirements_text.lower()) + self.assertNotIn("python-xlib", requirements_text.lower()) def test_fresh_install_creates_managed_paths_and_starts_service(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/uv.lock b/uv.lock index 63e57ec..6f18532 100644 --- a/uv.lock +++ b/uv.lock @@ -15,8 +15,6 @@ dependencies = [ { name = "llama-cpp-python" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pygobject" }, - { name = "python-xlib" }, { name = "sounddevice" }, ] @@ -25,8 +23,6 @@ requires-dist = [ { name = "faster-whisper" }, { name = "llama-cpp-python" }, { name = "numpy" }, - { name = "pygobject" }, - { name = "python-xlib" }, { name = "sounddevice" }, ] @@ -740,31 +736,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] -[[package]] -name = "pycairo" -version = "1.29.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/e2/c08847af2a103517f7785830706b6d1d55274494d76ab605eb744404c22f/pycairo-1.29.0-cp310-cp310-win32.whl", hash = "sha256:96c67e6caba72afd285c2372806a0175b1aa2f4537aa88fb4d9802d726effcd1", size = 751339, upload-time = "2025-11-11T19:11:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/eb/36/2a934c6fd4f32d2011c4d9cc59a32e34e06a97dd9f4b138614078d39340b/pycairo-1.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:65bddd944aee9f7d7d72821b1c87e97593856617c2820a78d589d66aa8afbd08", size = 845074, upload-time = "2025-11-11T19:11:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f0/ee0a887d8c8a6833940263b7234aaa63d8d95a27d6130a9a053867ff057c/pycairo-1.29.0-cp310-cp310-win_arm64.whl", hash = "sha256:15b36aea699e2ff215cb6a21501223246032e572a3a10858366acdd69c81a1c8", size = 694758, upload-time = "2025-11-11T19:11:32.635Z" }, - { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" }, - { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" }, - { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, - { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, - { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, - { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, - { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, - { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -774,27 +745,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] -[[package]] -name = "pygobject" -version = "3.54.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycairo" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/a5/68f883df1d8442e3b267cb92105a4b2f0de819bd64ac9981c2d680d3f49f/pygobject-3.54.5.tar.gz", hash = "sha256:b6656f6348f5245606cf15ea48c384c7f05156c75ead206c1b246c80a22fb585", size = 1274658, upload-time = "2025-10-18T13:45:03.121Z" } - -[[package]] -name = "python-xlib" -version = "0.33" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -877,15 +827,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "sounddevice" version = "0.5.5"