Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/chirp/audio_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 26 additions & 16 deletions src/chirp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
1 change: 0 additions & 1 deletion tests/test_audio_feedback_cache.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import unittest
from unittest.mock import MagicMock, patch
from pathlib import Path
import logging

from chirp.audio_feedback import AudioFeedback
Expand Down
106 changes: 106 additions & 0 deletions tests/test_ui_status.py
Original file line number Diff line number Diff line change
@@ -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()