Skip to content

Commit 2ff1d9d

Browse files
Isaac Springerclaude
authored andcommitted
feat: add progress callback to transcription with Rich progress bar in CLI
Adds an optional on_progress(ratio: float) callback to WhisperTranscriber.transcribe() and _run_whisper(), and wires it to a Rich live progress bar in main.py so users see incremental feedback instead of silence during long transcriptions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b37e3b0 commit 2ff1d9d

3 files changed

Lines changed: 70 additions & 8 deletions

File tree

tests/test_transcriber.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,36 @@ def test_whisper_model_cached_across_instances():
9292

9393
assert mock_wm.call_count == 1
9494
assert t1._model is t2._model
95+
96+
97+
def test_transcribe_calls_progress_callback(tmp_path):
98+
"""on_progress callback should be called with values between 0.0 and 1.0."""
99+
import soundfile as sf
100+
from unittest.mock import patch, MagicMock
101+
import tinysteno.transcriber as mod
102+
103+
audio = np.zeros(32000, dtype=np.float32) # 2 seconds at 16kHz
104+
wav_path = tmp_path / "t.wav"
105+
sf.write(str(wav_path), audio, 16000)
106+
107+
seg1 = MagicMock()
108+
seg1.text = " hello"
109+
seg1.start = 0.5
110+
111+
seg2 = MagicMock()
112+
seg2.text = " world"
113+
seg2.start = 1.5
114+
115+
progress_values = []
116+
117+
mod._MODEL_CACHE.clear()
118+
with patch("tinysteno.transcriber.WhisperModel") as mock_wm:
119+
mock_model = MagicMock()
120+
mock_model.transcribe.return_value = (iter([seg1, seg2]), MagicMock(language="en"))
121+
mock_wm.return_value = mock_model
122+
t = mod.WhisperTranscriber()
123+
124+
t.transcribe(str(wav_path), on_progress=lambda r: progress_values.append(r))
125+
126+
assert len(progress_values) >= 1
127+
assert all(0.0 <= v <= 1.0 for v in progress_values)

tinysteno/main.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,24 @@ def _process_audio( # pylint: disable=too-many-arguments,too-many-positional-ar
106106
timestamp: datetime,
107107
) -> None:
108108
"""Shared pipeline: transcribe → summarize → export."""
109-
print("Transcribing...")
109+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
110+
110111
transcriber = WhisperTranscriber(
111112
model_size=config.get("whisper_model", "small")
112113
)
113-
result = transcriber.transcribe(wav_path, diarize=config.get("diarization", False))
114+
with Progress(
115+
SpinnerColumn(),
116+
TextColumn("[progress.description]{task.description}"),
117+
BarColumn(),
118+
TextColumn("{task.percentage:>3.0f}%"),
119+
transient=True,
120+
) as progress:
121+
task = progress.add_task("Transcribing...", total=100)
122+
result = transcriber.transcribe(
123+
wav_path,
124+
diarize=config.get("diarization", False),
125+
on_progress=lambda r: progress.update(task, completed=int(r * 100)),
126+
)
114127
logger.debug(f"Detected language: {result['detected_language']}")
115128
logger.debug(f"Duration: {result['duration_seconds']:.0f}s")
116129

tinysteno/transcriber.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Whisper transcription module for TinySteno."""
22

33
from pathlib import Path
4-
from typing import Optional
4+
from typing import Optional, Callable
55
import numpy as np
66
import soundfile as sf
77
from faster_whisper import WhisperModel
@@ -22,7 +22,12 @@ def __init__(self, model_size: str = "small"):
2222
_MODEL_CACHE[cache_key] = WhisperModel(model_size, device="cpu", compute_type="int8")
2323
self._model = _MODEL_CACHE[cache_key]
2424

25-
def transcribe(self, audio_path: str, diarize: bool = False) -> dict:
25+
def transcribe(
26+
self,
27+
audio_path: str,
28+
diarize: bool = False,
29+
on_progress: Optional[Callable[[float], None]] = None,
30+
) -> dict:
2631
"""Transcribe an audio file and return results.
2732
2833
Returns:
@@ -38,7 +43,7 @@ def transcribe(self, audio_path: str, diarize: bool = False) -> dict:
3843
is_stereo = data.ndim == 2 and data.shape[1] >= 2
3944

4045
audio_16k = self._convert_to_16khz_array(data, sr)
41-
text, language = self._run_whisper(audio_16k)
46+
text, language = self._run_whisper(audio_16k, on_progress=on_progress)
4247
duration = len(audio_16k) / 16000.0
4348

4449
diarised_text = None
@@ -52,11 +57,22 @@ def transcribe(self, audio_path: str, diarize: bool = False) -> dict:
5257
"detected_language": language,
5358
}
5459

55-
def _run_whisper(self, audio: np.ndarray) -> tuple[str, str]:
60+
def _run_whisper(
61+
self,
62+
audio: np.ndarray,
63+
on_progress: Optional[Callable[[float], None]] = None,
64+
) -> tuple[str, str]:
5665
"""Run Whisper on a 16kHz mono float32 numpy array."""
66+
duration = len(audio) / 16000.0
5767
segments, info = self._model.transcribe(audio, beam_size=5)
58-
text = "".join(segment.text for segment in segments)
59-
return text, info.language
68+
parts = []
69+
for seg in segments:
70+
parts.append(seg.text)
71+
if on_progress is not None and duration > 0:
72+
on_progress(min(seg.start / duration, 1.0))
73+
if on_progress is not None:
74+
on_progress(1.0)
75+
return "".join(parts), info.language
6076

6177
def _run_whisper_segments(self, audio: np.ndarray) -> list[tuple[float, str]]:
6278
"""Run Whisper and return (start_seconds, text) tuples."""

0 commit comments

Comments
 (0)