diff --git a/src/aman.py b/src/aman.py index 49880c9..dc8f227 100755 --- a/src/aman.py +++ b/src/aman.py @@ -63,6 +63,7 @@ class Daemon: self.verbose = verbose self.lock = threading.Lock() self._shutdown_requested = threading.Event() + self._paused = False self.state = State.IDLE self.stream = None self.record = None @@ -108,6 +109,24 @@ class Daemon: def request_shutdown(self): self._shutdown_requested.set() + def is_paused(self) -> bool: + with self.lock: + return self._paused + + def toggle_paused(self) -> bool: + with self.lock: + self._paused = not self._paused + paused = self._paused + logging.info("pause %s", "enabled" if paused else "disabled") + return paused + + def apply_config(self, cfg: Config) -> None: + with self.lock: + self.cfg = cfg + self.vocabulary = VocabularyEngine(cfg.vocabulary) + self._stt_hint_kwargs_cache = None + logging.info("applied new runtime config") + def toggle(self): should_stop = False with self.lock: @@ -115,6 +134,9 @@ class Daemon: logging.info("shutdown in progress, trigger ignored") return if self.state == State.IDLE: + if self._paused: + logging.info("paused, trigger ignored") + return self._start_recording_locked() return if self.state == State.RECORDING: @@ -447,9 +469,10 @@ def _init_command(args: argparse.Namespace) -> int: def _run_command(args: argparse.Namespace) -> int: global _LOCK_HANDLE + config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH try: - cfg = load(args.config) + cfg = load(str(config_path)) except ConfigValidationError as exc: logging.error("startup failed: invalid config field '%s': %s", exc.field, exc.reason) if exc.example_fix: @@ -463,7 +486,7 @@ def _run_command(args: argparse.Namespace) -> int: logging.info("hotkey: %s", cfg.daemon.hotkey) logging.info( "config (%s):\n%s", - args.config or str(Path.home() / ".config" / "aman" / "config.json"), + str(config_path), json.dumps(redacted_dict(cfg), indent=2), ) logging.info( @@ -491,6 +514,10 @@ def _run_command(args: argparse.Namespace) -> int: return shutdown_once.set() logging.info("%s, shutting down", reason) + try: + desktop.stop_hotkey_listener() + except Exception as exc: + logging.debug("failed to stop hotkey listener: %s", exc) if not daemon.shutdown(timeout=5.0): logging.warning("timed out waiting for idle state during shutdown") desktop.request_quit() @@ -501,18 +528,73 @@ def _run_command(args: argparse.Namespace) -> int: signal.signal(signal.SIGINT, handle_signal) signal.signal(signal.SIGTERM, handle_signal) + def hotkey_callback(): + if args.dry_run: + logging.info("hotkey pressed (dry-run)") + return + daemon.toggle() + + def reload_config_callback(): + nonlocal cfg + try: + new_cfg = load(str(config_path)) + except ConfigValidationError as exc: + logging.error("reload failed: invalid config field '%s': %s", exc.field, exc.reason) + if exc.example_fix: + logging.error("reload example fix: %s", exc.example_fix) + return + except Exception as exc: + logging.error("reload failed: %s", exc) + return + try: + desktop.start_hotkey_listener(new_cfg.daemon.hotkey, hotkey_callback) + except Exception as exc: + logging.error("reload failed: could not apply hotkey '%s': %s", new_cfg.daemon.hotkey, exc) + return + daemon.apply_config(new_cfg) + cfg = new_cfg + logging.info("config reloaded from %s", config_path) + + def run_diagnostics_callback(): + report = run_diagnostics(str(config_path)) + if report.ok: + logging.info("diagnostics passed (%d checks)", len(report.checks)) + return + failed = [check for check in report.checks if not check.ok] + logging.warning("diagnostics failed (%d/%d checks)", len(failed), len(report.checks)) + for check in failed: + if check.hint: + logging.warning("%s: %s | hint: %s", check.id, check.message, check.hint) + else: + logging.warning("%s: %s", check.id, check.message) + + def open_config_path_callback(): + logging.info("config path: %s", config_path) + try: desktop.start_hotkey_listener( cfg.daemon.hotkey, - lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(), + hotkey_callback, ) except Exception as exc: logging.error("hotkey setup failed: %s", exc) return 1 logging.info("ready") try: - desktop.run_tray(daemon.get_state, lambda: shutdown("quit requested")) + desktop.run_tray( + daemon.get_state, + lambda: shutdown("quit requested"), + is_paused_getter=daemon.is_paused, + on_toggle_pause=daemon.toggle_paused, + on_reload_config=reload_config_callback, + on_run_diagnostics=run_diagnostics_callback, + on_open_config=open_config_path_callback, + ) finally: + try: + desktop.stop_hotkey_listener() + except Exception: + pass daemon.shutdown(timeout=1.0) return 0 diff --git a/src/desktop.py b/src/desktop.py index 4e448d5..d995b5e 100644 --- a/src/desktop.py +++ b/src/desktop.py @@ -8,6 +8,9 @@ class DesktopAdapter(Protocol): def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None: raise NotImplementedError + def stop_hotkey_listener(self) -> None: + raise NotImplementedError + def validate_hotkey(self, hotkey: str) -> None: raise NotImplementedError @@ -26,7 +29,17 @@ class DesktopAdapter(Protocol): ) -> None: raise NotImplementedError - def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None: + def run_tray( + self, + state_getter: Callable[[], str], + on_quit: Callable[[], None], + *, + is_paused_getter: Callable[[], bool] | None = None, + on_toggle_pause: Callable[[], None] | None = None, + on_reload_config: Callable[[], None] | None = None, + on_run_diagnostics: Callable[[], None] | None = None, + on_open_config: Callable[[], None] | None = None, + ) -> None: raise NotImplementedError def request_quit(self) -> None: diff --git a/src/desktop_wayland.py b/src/desktop_wayland.py index 26c2894..25b8678 100644 --- a/src/desktop_wayland.py +++ b/src/desktop_wayland.py @@ -7,6 +7,9 @@ class WaylandAdapter: def start_hotkey_listener(self, _hotkey: str, _callback: Callable[[], None]) -> None: raise SystemExit("Wayland hotkeys are not supported yet.") + def stop_hotkey_listener(self) -> None: + raise SystemExit("Wayland hotkeys are not supported yet.") + def validate_hotkey(self, _hotkey: str) -> None: raise SystemExit("Wayland hotkeys are not supported yet.") @@ -26,7 +29,24 @@ class WaylandAdapter: _ = remove_transcription_from_clipboard raise SystemExit("Wayland text injection is not supported yet.") - def run_tray(self, _state_getter: Callable[[], str], _on_quit: Callable[[], None]) -> None: + def run_tray( + self, + _state_getter: Callable[[], str], + _on_quit: Callable[[], None], + *, + is_paused_getter: Callable[[], bool] | None = None, + on_toggle_pause: Callable[[], None] | None = None, + on_reload_config: Callable[[], None] | None = None, + on_run_diagnostics: Callable[[], None] | None = None, + on_open_config: Callable[[], None] | None = None, + ) -> None: + _ = ( + is_paused_getter, + on_toggle_pause, + on_reload_config, + on_run_diagnostics, + on_open_config, + ) raise SystemExit("Wayland tray support is not available yet.") def request_quit(self) -> None: diff --git a/src/desktop_x11.py b/src/desktop_x11.py index cfaeeee..ef74550 100644 --- a/src/desktop_x11.py +++ b/src/desktop_x11.py @@ -42,9 +42,14 @@ class X11Adapter: self.indicator = None self.status_icon = None self.menu = None + self._hotkey_listener_lock = threading.Lock() + self._hotkey_listener_stop_event: threading.Event | None = None + self._hotkey_listener_thread: threading.Thread | None = None self._cancel_listener_lock = threading.Lock() self._cancel_listener_stop_event: threading.Event | None = None self._cancel_listener_callback: Callable[[], None] | None = None + self._pause_item: Gtk.MenuItem | None = None + self._pause_state_getter: Callable[[], bool] | None = None if AppIndicator3 is not None: self.indicator = AppIndicator3.Indicator.new( "aman", @@ -70,9 +75,28 @@ class X11Adapter: def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None: mods, keysym = self._parse_hotkey(hotkey) self._validate_hotkey_registration(mods, keysym) - thread = threading.Thread(target=self._listen, args=(mods, keysym, callback), daemon=True) + stop_event = threading.Event() + thread = threading.Thread( + target=self._listen, + args=(mods, keysym, callback, stop_event), + daemon=True, + ) + with self._hotkey_listener_lock: + previous_stop_event = self._hotkey_listener_stop_event + self._hotkey_listener_stop_event = stop_event + self._hotkey_listener_thread = thread + if previous_stop_event is not None: + previous_stop_event.set() thread.start() + def stop_hotkey_listener(self) -> None: + with self._hotkey_listener_lock: + stop_event = self._hotkey_listener_stop_event + self._hotkey_listener_stop_event = None + self._hotkey_listener_thread = None + if stop_event is not None: + stop_event.set() + def validate_hotkey(self, hotkey: str) -> None: mods, keysym = self._parse_hotkey(hotkey) self._validate_hotkey_registration(mods, keysym) @@ -137,8 +161,35 @@ class X11Adapter: text = clipboard.wait_for_text() return str(text) if text is not None else None - def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None: + def run_tray( + self, + state_getter: Callable[[], str], + on_quit: Callable[[], None], + *, + is_paused_getter: Callable[[], bool] | None = None, + on_toggle_pause: Callable[[], None] | None = None, + on_reload_config: Callable[[], None] | None = None, + on_run_diagnostics: Callable[[], None] | None = None, + on_open_config: Callable[[], None] | None = None, + ) -> None: + self._pause_state_getter = is_paused_getter self.menu = Gtk.Menu() + if on_toggle_pause is not None: + self._pause_item = Gtk.MenuItem(label="Pause Aman") + self._pause_item.connect("activate", lambda *_: on_toggle_pause()) + self.menu.append(self._pause_item) + if on_reload_config is not None: + reload_item = Gtk.MenuItem(label="Reload Config") + reload_item.connect("activate", lambda *_: on_reload_config()) + self.menu.append(reload_item) + if on_run_diagnostics is not None: + diagnostics_item = Gtk.MenuItem(label="Run Diagnostics") + diagnostics_item.connect("activate", lambda *_: on_run_diagnostics()) + self.menu.append(diagnostics_item) + if on_open_config is not None: + open_config_item = Gtk.MenuItem(label="Open Config Path") + open_config_item.connect("activate", lambda *_: on_open_config()) + self.menu.append(open_config_item) quit_item = Gtk.MenuItem(label="Quit") quit_item.connect("activate", lambda *_: self._handle_quit(on_quit)) self.menu.append(quit_item) @@ -340,6 +391,12 @@ class X11Adapter: return "Idle" def _update_tray(self, state_getter: Callable[[], str]): + if self._pause_item is not None and self._pause_state_getter is not None: + try: + paused = self._pause_state_getter() + self._pause_item.set_label("Resume Aman" if paused else "Pause Aman") + except Exception: + self._pause_item.set_label("Pause Aman") state = state_getter() icon_path = self._icon_path(state) if self.indicator is not None: diff --git a/tests/test_aman.py b/tests/test_aman.py index 4091137..b917033 100644 --- a/tests/test_aman.py +++ b/tests/test_aman.py @@ -333,6 +333,18 @@ class DaemonTests(unittest.TestCase): self.assertEqual(ai_processor.last_kwargs.get("profile"), "fast") + @patch("aman.start_audio_recording") + def test_paused_state_blocks_recording_start(self, start_mock): + desktop = FakeDesktop() + daemon = self._build_daemon(desktop, FakeModel(), verbose=False) + + self.assertTrue(daemon.toggle_paused()) + daemon.toggle() + + start_mock.assert_not_called() + self.assertEqual(daemon.get_state(), aman.State.IDLE) + self.assertEqual(desktop.cancel_listener_start_calls, 0) + class LockTests(unittest.TestCase): def test_lock_rejects_second_instance(self):