diff --git a/src/chirp/audio_feedback.py b/src/chirp/audio_feedback.py index 45ca0a4..a1051bc 100644 --- a/src/chirp/audio_feedback.py +++ b/src/chirp/audio_feedback.py @@ -5,7 +5,7 @@ import wave from contextlib import contextmanager from pathlib import Path -from typing import Any, Dict, Iterator, Optional, Tuple, Union +from typing import Any, Dict, Iterator, Optional from importlib import resources diff --git a/src/chirp/main.py b/src/chirp/main.py index 19ea709..95edc5a 100644 --- a/src/chirp/main.py +++ b/src/chirp/main.py @@ -61,6 +61,9 @@ def __init__(self, *, verbose: bool = False) -> None: if not console: console = Console(stderr=True) + self.console = console + self.status_indicator = self.console.status("Ready", spinner="dots") + try: with console.status("[bold green]Initializing Parakeet model...[/bold green]", spinner="dots"): self.parakeet = ParakeetManager( @@ -123,6 +126,8 @@ def _start_recording(self) -> None: return self._recording = True self.audio_feedback.play_start(self.config.start_sound_path) + self.status_indicator.update("Recording...", spinner="dots") + self.status_indicator.start() self.logger.info("Recording started") if self.config.max_recording_duration > 0: @@ -144,27 +149,32 @@ def _stop_recording(self) -> None: waveform = self.audio_capture.stop() self._recording = False self.audio_feedback.play_stop(self.config.stop_sound_path) + self.status_indicator.update("Transcribing...", spinner="dots") self.logger.info("Recording stopped (%s samples)", waveform.size) self._executor.submit(self._transcribe_and_inject, waveform) def _transcribe_and_inject(self, waveform) -> None: - start_time = time.perf_counter() - if waveform.size == 0: - self.logger.warning("No audio samples captured") - return try: - text = self.parakeet.transcribe(waveform, sample_rate=16_000, language=self.config.language) - except Exception as exc: - self.logger.exception("Transcription failed: %s", exc) - self.audio_feedback.play_error(self.config.error_sound_path) - return - duration = time.perf_counter() - start_time - self.logger.debug("Transcription finished in %.2fs (chars=%s)", duration, len(text)) - if not text.strip(): - self.logger.info("Transcription empty; skipping paste") - return - self.logger.debug("Transcription: %s", text) - self.text_injector.inject(text) + start_time = time.perf_counter() + if waveform.size == 0: + self.logger.warning("No audio samples captured") + return + try: + text = self.parakeet.transcribe(waveform, sample_rate=16_000, language=self.config.language) + except Exception as exc: + self.logger.exception("Transcription failed: %s", exc) + self.audio_feedback.play_error(self.config.error_sound_path) + return + duration = time.perf_counter() - start_time + self.logger.debug("Transcription finished in %.2fs (chars=%s)", duration, len(text)) + if not text.strip(): + self.logger.info("Transcription empty; skipping paste") + return + self.logger.debug("Transcription: %s", text) + self.text_injector.inject(text) + finally: + if not self._recording: + self.status_indicator.stop() def _log_capture_status(self, message: str) -> None: self.logger.debug("Audio status: %s", message) diff --git a/tests/test_audio_feedback_cache.py b/tests/test_audio_feedback_cache.py index 5bc3f3e..e8a5cc6 100644 --- a/tests/test_audio_feedback_cache.py +++ b/tests/test_audio_feedback_cache.py @@ -1,6 +1,5 @@ import unittest from unittest.mock import MagicMock, patch -from pathlib import Path import logging from chirp.audio_feedback import AudioFeedback diff --git a/tests/test_ui_status.py b/tests/test_ui_status.py new file mode 100644 index 0000000..064b48c --- /dev/null +++ b/tests/test_ui_status.py @@ -0,0 +1,106 @@ +import sys +from unittest.mock import MagicMock + +# Mock sounddevice before importing chirp.main because it fails on import if PortAudio is missing +sys.modules["sounddevice"] = MagicMock() +sys.modules["winsound"] = MagicMock() + +import logging # noqa: E402 +import unittest # noqa: E402 +from unittest.mock import patch # noqa: E402 +from rich.logging import RichHandler # noqa: E402 + +from chirp.main import ChirpApp # noqa: E402 + + +class TestChirpAppUI(unittest.TestCase): + @patch("chirp.main.get_logger") + @patch("chirp.main.ConfigManager") + @patch("chirp.main.KeyboardShortcutManager") + @patch("chirp.main.AudioCapture") + @patch("chirp.main.AudioFeedback") + @patch("chirp.main.ParakeetManager") + @patch("chirp.main.TextInjector") + @patch("chirp.main.Console") + # Removed RichHandler patch + def setUp(self, mock_console, mock_text_injector, mock_parakeet, + mock_audio_feedback, mock_audio_capture, mock_keyboard, mock_config, mock_get_logger): + + # Setup mocks + self.mock_logger = MagicMock(spec=logging.Logger) + self.mock_logger.handlers = [] + mock_get_logger.return_value = self.mock_logger + + # Mock Config + mock_config_instance = mock_config.return_value + mock_config_instance.load.return_value = MagicMock( + parakeet_model="test-model", + parakeet_quantization=None, + onnx_providers="cpu", + threads=0, + paste_mode="ctrl", + audio_feedback=False, + word_overrides={}, + post_processing="", + clipboard_behavior=False, + clipboard_clear_delay=0.0, + max_recording_duration=0, + error_sound_path="", + start_sound_path="", + stop_sound_path="", + language="en" + ) + + # Mock Console and Status + self.mock_console_instance = mock_console.return_value + self.mock_status = MagicMock() + self.mock_console_instance.status.return_value = self.mock_status + + # Setup logger handlers to include a real RichHandler with our mock console + # We need to construct it carefully or just mock the isinstance check? + # Creating a real RichHandler requires a Console. + # We can pass our mock console to it. + real_handler = RichHandler(console=self.mock_console_instance) + self.mock_logger.handlers = [real_handler] + + self.app = ChirpApp() + + # Manually attach the status indicator since we haven't implemented it in __init__ yet. + self.app.status_indicator = self.mock_status + + def test_init_creates_status(self): + # This test verifies that status was called during init + self.mock_console_instance.status.assert_called_with("[bold green]Initializing Parakeet model...[/bold green]", spinner="dots") + + def test_start_recording_updates_status(self): + self.app._start_recording() + self.mock_status.update.assert_called_with("Recording...", spinner="dots") + self.mock_status.start.assert_called_once() + + def test_stop_recording_updates_status(self): + self.app._recording = True + self.app._executor = MagicMock() + self.app._stop_recording() + self.mock_status.update.assert_called_with("Transcribing...", spinner="dots") + + def test_transcribe_stops_status_when_done(self): + waveform = MagicMock() + waveform.size = 100 + + self.app.parakeet = MagicMock() + self.app.parakeet.transcribe.return_value = "test" + + self.app._recording = False + self.app._transcribe_and_inject(waveform) + self.mock_status.stop.assert_called_once() + + def test_transcribe_does_not_stop_status_if_recording(self): + waveform = MagicMock() + waveform.size = 100 + + self.app.parakeet = MagicMock() + self.app.parakeet.transcribe.return_value = "test" + + self.app._recording = True + self.app._transcribe_and_inject(waveform) + self.mock_status.stop.assert_not_called()