From 71806cd0b87735136ef91055659f5198613eede9 Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Fri, 10 Apr 2026 21:21:29 +0200 Subject: [PATCH] feat: integrate tray icon, settings dialog, and config reload into App Co-Authored-By: Claude Sonnet 4.6 --- tests/test_main.py | 61 ++++++++++++++++++++++++++++++++++++--- whisper_local/__main__.py | 49 ++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 42d83ed..498d818 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,20 +7,22 @@ from whisper_local.__main__ import App class TestApp: + @patch("whisper_local.__main__.create_tray") @patch("whisper_local.__main__.Transcriber") @patch("whisper_local.__main__.create_listener") @patch("whisper_local.__main__.create_inserter") - def test_app_init(self, mock_inserter_factory, mock_listener_factory, mock_transcriber_class): + def test_app_init(self, mock_inserter_factory, mock_listener_factory, mock_transcriber_class, mock_tray_factory): app = App() assert app.recorder is not None mock_transcriber_class.assert_called_once() mock_listener_factory.assert_called_once() mock_inserter_factory.assert_called_once() + @patch("whisper_local.__main__.create_tray") @patch("whisper_local.__main__.Transcriber") @patch("whisper_local.__main__.create_listener") @patch("whisper_local.__main__.create_inserter") - def test_on_press_starts_recording(self, mock_inserter_factory, mock_listener_factory, mock_transcriber_class): + def test_on_press_starts_recording(self, mock_inserter_factory, mock_listener_factory, mock_transcriber_class, mock_tray_factory): app = App() app.recorder = MagicMock() @@ -29,10 +31,11 @@ class TestApp: app.recorder.start.assert_called_once() + @patch("whisper_local.__main__.create_tray") @patch("whisper_local.__main__.Transcriber") @patch("whisper_local.__main__.create_listener") @patch("whisper_local.__main__.create_inserter") - def test_on_release_stops_and_transcribes(self, mock_inserter_factory, mock_listener_factory, mock_transcriber_class): + def test_on_release_stops_and_transcribes(self, mock_inserter_factory, mock_listener_factory, mock_transcriber_class, mock_tray_factory): mock_transcriber = MagicMock() mock_transcriber.transcribe.return_value = "Hallo" mock_transcriber_class.return_value = mock_transcriber @@ -51,10 +54,11 @@ class TestApp: mock_transcriber.transcribe.assert_called_once_with(audio) app.inserter.insert.assert_awaited_once_with("Hallo") + @patch("whisper_local.__main__.create_tray") @patch("whisper_local.__main__.Transcriber") @patch("whisper_local.__main__.create_listener") @patch("whisper_local.__main__.create_inserter") - def test_on_release_no_audio_skips(self, mock_inserter_factory, mock_listener_factory, mock_transcriber_class): + def test_on_release_no_audio_skips(self, mock_inserter_factory, mock_listener_factory, mock_transcriber_class, mock_tray_factory): mock_transcriber = MagicMock() mock_transcriber_class.return_value = mock_transcriber @@ -69,3 +73,52 @@ class TestApp: mock_transcriber.transcribe.assert_not_called() app.inserter.insert.assert_not_awaited() + + @patch("whisper_local.__main__.create_tray") + @patch("whisper_local.__main__.Transcriber") + @patch("whisper_local.__main__.create_listener") + @patch("whisper_local.__main__.create_inserter") + def test_on_press_sets_recording_state( + self, mock_inserter_factory, mock_listener_factory, + mock_transcriber_class, mock_tray_factory + ): + from whisper_local.tray import AppState + mock_tray = MagicMock() + mock_tray_factory.return_value = mock_tray + + app = App() + app.recorder = MagicMock() + + import asyncio + asyncio.run(app.on_press()) + + mock_tray.set_state.assert_called_with(AppState.RECORDING) + + @patch("whisper_local.__main__.create_tray") + @patch("whisper_local.__main__.Transcriber") + @patch("whisper_local.__main__.create_listener") + @patch("whisper_local.__main__.create_inserter") + def test_on_release_sets_waiting_state_after_transcription( + self, mock_inserter_factory, mock_listener_factory, + mock_transcriber_class, mock_tray_factory + ): + from whisper_local.tray import AppState + mock_tray = MagicMock() + mock_tray_factory.return_value = mock_tray + + mock_transcriber = MagicMock() + mock_transcriber.transcribe.return_value = "Text" + mock_transcriber_class.return_value = mock_transcriber + + app = App() + app.recorder = MagicMock() + app.recorder.stop.return_value = np.zeros(16000, dtype=np.float32) + app.inserter = MagicMock() + app.inserter.insert = AsyncMock() + + import asyncio + asyncio.run(app.on_release()) + + calls = [c.args[0] for c in mock_tray.set_state.call_args_list] + assert AppState.TRANSCRIBING in calls + assert calls[-1] == AppState.WAITING diff --git a/whisper_local/__main__.py b/whisper_local/__main__.py index 617eeb3..3e7d242 100644 --- a/whisper_local/__main__.py +++ b/whisper_local/__main__.py @@ -9,6 +9,7 @@ from whisper_local.hotkey import create_listener from whisper_local.inserter import create_inserter from whisper_local.recorder import Recorder from whisper_local.transcriber import Transcriber +from whisper_local.tray import AppState, create_tray logger = logging.getLogger(__name__) @@ -18,10 +19,15 @@ class App: if config is None: config = load_config() + self._config = config + self._loop: asyncio.AbstractEventLoop | None = None + self._hotkey_task: asyncio.Task | None = None + self.recorder = Recorder( sample_rate=config.sample_rate, channels=config.channels, min_duration=config.min_duration, + device=config.microphone or None, ) self.transcriber = Transcriber( model_name=config.whisper_model, @@ -32,10 +38,12 @@ class App: self.hotkey = create_listener(key_name=config.hotkey) self.hotkey.on_press = self.on_press self.hotkey.on_release = self.on_release + self.tray = create_tray(on_settings=self._open_settings, on_quit=self._quit) async def on_press(self) -> None: """Callback: Hotkey gedrückt — Aufnahme starten.""" logger.info("Aufnahme startet...") + self.tray.set_state(AppState.RECORDING) self.recorder.start() async def on_release(self) -> None: @@ -43,17 +51,56 @@ class App: audio = self.recorder.stop() if audio is None: logger.info("Keine Audio-Daten, übersprungen") + self.tray.set_state(AppState.WAITING) return logger.info("Transkribiere...") + self.tray.set_state(AppState.TRANSCRIBING) text = self.transcriber.transcribe(audio) if text: await self.inserter.insert(text) + self.tray.set_state(AppState.WAITING) + + def _quit(self) -> None: + """Beendet die Anwendung sauber.""" + if self._loop is not None: + self._loop.call_soon_threadsafe(self._loop.stop) + + def _open_settings(self) -> None: + """Öffnet den Einstellungs-Dialog.""" + from whisper_local.tray._settings import SettingsDialog + SettingsDialog(config=self._config, on_save=self._on_config_reload).open() + + def _on_config_reload(self, new_config: Config) -> None: + """Übernimmt neue Konfiguration ohne App-Neustart.""" + self._config = new_config + self.recorder = Recorder( + sample_rate=new_config.sample_rate, + channels=new_config.channels, + min_duration=new_config.min_duration, + device=new_config.microphone or None, + ) + if self._loop is not None: + asyncio.run_coroutine_threadsafe( + self._restart_hotkey(new_config.hotkey), self._loop + ) + + async def _restart_hotkey(self, key_name: str) -> None: + """Stoppt den alten Hotkey-Listener und startet einen neuen.""" + self.hotkey.stop() + await asyncio.sleep(0.1) + self.hotkey = create_listener(key_name=key_name) + self.hotkey.on_press = self.on_press + self.hotkey.on_release = self.on_release + self._hotkey_task = asyncio.create_task(self.hotkey.listen()) async def run(self) -> None: """Startet den Hauptloop.""" + self._loop = asyncio.get_running_loop() logger.info("whisper-local gestartet, warte auf Hotkey...") - await self.hotkey.listen() + self.tray.start() + self._hotkey_task = asyncio.create_task(self.hotkey.listen()) + await self._hotkey_task def main():