feat: integrate tray icon, settings dialog, and config reload into App
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+57
-4
@@ -7,20 +7,22 @@ from whisper_local.__main__ import App
|
|||||||
|
|
||||||
|
|
||||||
class TestApp:
|
class TestApp:
|
||||||
|
@patch("whisper_local.__main__.create_tray")
|
||||||
@patch("whisper_local.__main__.Transcriber")
|
@patch("whisper_local.__main__.Transcriber")
|
||||||
@patch("whisper_local.__main__.create_listener")
|
@patch("whisper_local.__main__.create_listener")
|
||||||
@patch("whisper_local.__main__.create_inserter")
|
@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()
|
app = App()
|
||||||
assert app.recorder is not None
|
assert app.recorder is not None
|
||||||
mock_transcriber_class.assert_called_once()
|
mock_transcriber_class.assert_called_once()
|
||||||
mock_listener_factory.assert_called_once()
|
mock_listener_factory.assert_called_once()
|
||||||
mock_inserter_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__.Transcriber")
|
||||||
@patch("whisper_local.__main__.create_listener")
|
@patch("whisper_local.__main__.create_listener")
|
||||||
@patch("whisper_local.__main__.create_inserter")
|
@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 = App()
|
||||||
app.recorder = MagicMock()
|
app.recorder = MagicMock()
|
||||||
|
|
||||||
@@ -29,10 +31,11 @@ class TestApp:
|
|||||||
|
|
||||||
app.recorder.start.assert_called_once()
|
app.recorder.start.assert_called_once()
|
||||||
|
|
||||||
|
@patch("whisper_local.__main__.create_tray")
|
||||||
@patch("whisper_local.__main__.Transcriber")
|
@patch("whisper_local.__main__.Transcriber")
|
||||||
@patch("whisper_local.__main__.create_listener")
|
@patch("whisper_local.__main__.create_listener")
|
||||||
@patch("whisper_local.__main__.create_inserter")
|
@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 = MagicMock()
|
||||||
mock_transcriber.transcribe.return_value = "Hallo"
|
mock_transcriber.transcribe.return_value = "Hallo"
|
||||||
mock_transcriber_class.return_value = mock_transcriber
|
mock_transcriber_class.return_value = mock_transcriber
|
||||||
@@ -51,10 +54,11 @@ class TestApp:
|
|||||||
mock_transcriber.transcribe.assert_called_once_with(audio)
|
mock_transcriber.transcribe.assert_called_once_with(audio)
|
||||||
app.inserter.insert.assert_awaited_once_with("Hallo")
|
app.inserter.insert.assert_awaited_once_with("Hallo")
|
||||||
|
|
||||||
|
@patch("whisper_local.__main__.create_tray")
|
||||||
@patch("whisper_local.__main__.Transcriber")
|
@patch("whisper_local.__main__.Transcriber")
|
||||||
@patch("whisper_local.__main__.create_listener")
|
@patch("whisper_local.__main__.create_listener")
|
||||||
@patch("whisper_local.__main__.create_inserter")
|
@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 = MagicMock()
|
||||||
mock_transcriber_class.return_value = mock_transcriber
|
mock_transcriber_class.return_value = mock_transcriber
|
||||||
|
|
||||||
@@ -69,3 +73,52 @@ class TestApp:
|
|||||||
|
|
||||||
mock_transcriber.transcribe.assert_not_called()
|
mock_transcriber.transcribe.assert_not_called()
|
||||||
app.inserter.insert.assert_not_awaited()
|
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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from whisper_local.hotkey import create_listener
|
|||||||
from whisper_local.inserter import create_inserter
|
from whisper_local.inserter import create_inserter
|
||||||
from whisper_local.recorder import Recorder
|
from whisper_local.recorder import Recorder
|
||||||
from whisper_local.transcriber import Transcriber
|
from whisper_local.transcriber import Transcriber
|
||||||
|
from whisper_local.tray import AppState, create_tray
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,10 +19,15 @@ class App:
|
|||||||
if config is None:
|
if config is None:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
|
self._config = config
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._hotkey_task: asyncio.Task | None = None
|
||||||
|
|
||||||
self.recorder = Recorder(
|
self.recorder = Recorder(
|
||||||
sample_rate=config.sample_rate,
|
sample_rate=config.sample_rate,
|
||||||
channels=config.channels,
|
channels=config.channels,
|
||||||
min_duration=config.min_duration,
|
min_duration=config.min_duration,
|
||||||
|
device=config.microphone or None,
|
||||||
)
|
)
|
||||||
self.transcriber = Transcriber(
|
self.transcriber = Transcriber(
|
||||||
model_name=config.whisper_model,
|
model_name=config.whisper_model,
|
||||||
@@ -32,10 +38,12 @@ class App:
|
|||||||
self.hotkey = create_listener(key_name=config.hotkey)
|
self.hotkey = create_listener(key_name=config.hotkey)
|
||||||
self.hotkey.on_press = self.on_press
|
self.hotkey.on_press = self.on_press
|
||||||
self.hotkey.on_release = self.on_release
|
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:
|
async def on_press(self) -> None:
|
||||||
"""Callback: Hotkey gedrückt — Aufnahme starten."""
|
"""Callback: Hotkey gedrückt — Aufnahme starten."""
|
||||||
logger.info("Aufnahme startet...")
|
logger.info("Aufnahme startet...")
|
||||||
|
self.tray.set_state(AppState.RECORDING)
|
||||||
self.recorder.start()
|
self.recorder.start()
|
||||||
|
|
||||||
async def on_release(self) -> None:
|
async def on_release(self) -> None:
|
||||||
@@ -43,17 +51,56 @@ class App:
|
|||||||
audio = self.recorder.stop()
|
audio = self.recorder.stop()
|
||||||
if audio is None:
|
if audio is None:
|
||||||
logger.info("Keine Audio-Daten, übersprungen")
|
logger.info("Keine Audio-Daten, übersprungen")
|
||||||
|
self.tray.set_state(AppState.WAITING)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Transkribiere...")
|
logger.info("Transkribiere...")
|
||||||
|
self.tray.set_state(AppState.TRANSCRIBING)
|
||||||
text = self.transcriber.transcribe(audio)
|
text = self.transcriber.transcribe(audio)
|
||||||
if text:
|
if text:
|
||||||
await self.inserter.insert(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:
|
async def run(self) -> None:
|
||||||
"""Startet den Hauptloop."""
|
"""Startet den Hauptloop."""
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
logger.info("whisper-local gestartet, warte auf Hotkey...")
|
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():
|
def main():
|
||||||
|
|||||||
Reference in New Issue
Block a user