From 8f5772e66787256a3e9d825daddcf0d7b996cbe2 Mon Sep 17 00:00:00 2001 From: Suijiku <130406223+Suijiku@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:16:36 -0500 Subject: [PATCH 1/2] feat: batched Whisper inference with configurable batch size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the cached WhisperModel in faster-whisper's BatchedInferencePipeline for VAD-chunked parallel decoding (typically several times faster on long recordings). batch_size is configurable in Settings > Transcription (1-16, default 8) and persisted in config. batch_size == 1 keeps the classic sequential path as a safety hatch (retains condition_on_previous_text); >1 uses the batched pipeline. The segment loop is unchanged — batched segments still carry avg_logprob/start/end/text. Bumps faster-whisper floor to >=1.1.0 (BatchedInferencePipeline) in requirements.txt and pyproject.toml. --- app/main_window.py | 2 ++ app/transcription/transcriber.py | 30 +++++++++++++---- app/ui/settings_dialog.py | 14 ++++++++ app/utils/config.py | 1 + pyproject.toml | 2 +- requirements.txt | 2 +- tests/test_transcriber.py | 57 ++++++++++++++++++++++++++++++-- 7 files changed, 96 insertions(+), 12 deletions(-) diff --git a/app/main_window.py b/app/main_window.py index ba1f908..31822d4 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -768,12 +768,14 @@ def _start_transcription(self, audio_path, session=None): model_size = self.config.get("transcription", "model_size") language = self.config.get("transcription", "language") device = self.config.get("transcription", "device") + batch_size = self.config.get("transcription", "batch_size") self._transcription_worker = TranscriptionWorker( audio_path=audio_path, model_size=model_size, language=language, device=device, + batch_size=batch_size, ) self._transcription_worker.session = session self._transcription_worker.progress.connect(self._on_transcription_progress) diff --git a/app/transcription/transcriber.py b/app/transcription/transcriber.py index a7a22b4..75f91d8 100644 --- a/app/transcription/transcriber.py +++ b/app/transcription/transcriber.py @@ -142,12 +142,17 @@ class TranscriptionWorker(QThread): cancelled = pyqtSignal() - def __init__(self, audio_path, model_size="base", language=None, device="cpu"): + def __init__(self, audio_path, model_size="base", language=None, device="cpu", + batch_size=8): super().__init__() self.audio_path = audio_path self.model_size = model_size self.language = language self.device = device + # batch_size > 1 uses faster-whisper's BatchedInferencePipeline (VAD-chunked + # parallel decode, typically several times faster). batch_size == 1 keeps the + # classic sequential path (which retains condition_on_previous_text). + self.batch_size = batch_size self._cancel_requested = False def cancel(self): @@ -180,12 +185,23 @@ def run(self): self.cancelled.emit() return - self.progress.emit("Transcribing audio...") - segments_gen, info = model.transcribe( - self.audio_path, - language=self.language, - vad_filter=True, - ) + if self.batch_size and self.batch_size > 1: + self.progress.emit(f"Transcribing audio (batched, batch size {self.batch_size})...") + from faster_whisper import BatchedInferencePipeline + pipeline = BatchedInferencePipeline(model=model) + segments_gen, info = pipeline.transcribe( + self.audio_path, + language=self.language, + vad_filter=True, + batch_size=self.batch_size, + ) + else: + self.progress.emit("Transcribing audio...") + segments_gen, info = model.transcribe( + self.audio_path, + language=self.language, + vad_filter=True, + ) result = TranscriptResult( language=info.language, diff --git a/app/ui/settings_dialog.py b/app/ui/settings_dialog.py index be75cab..7f3abe5 100644 --- a/app/ui/settings_dialog.py +++ b/app/ui/settings_dialog.py @@ -240,6 +240,16 @@ def _setup_ui(self): ) whisper_form.addRow("Min duration to auto-transcribe:", self.min_duration_spin) + self.batch_size_spin = QSpinBox() + self.batch_size_spin.setRange(1, 16) + self.batch_size_spin.setSpecialValueText("1 (sequential / classic)") + self.batch_size_spin.setToolTip( + "Batched inference decodes VAD-chunked audio in parallel — typically\n" + "several times faster on long recordings. Higher values use more RAM.\n" + "Set to 1 for the classic sequential path (keeps cross-chunk context)." + ) + whisper_form.addRow("Batch size:", self.batch_size_spin) + transcription_layout.addWidget(whisper_group) # Diarization group @@ -426,6 +436,9 @@ def _load_settings(self): min_dur = self.config.get("transcription", "min_duration") self.min_duration_spin.setValue(min_dur if min_dur else 0) + batch_size = self.config.get("transcription", "batch_size") + self.batch_size_spin.setValue(batch_size if batch_size else 8) + # Diarization self.diarization_enabled.setChecked(self.config.get("diarization", "enabled")) self.hf_token_edit.setText(self.config.get("diarization", "hf_token") or "") @@ -484,6 +497,7 @@ def _save_and_close(self): lang = self.language_edit.text().strip() self.config.set("transcription", "language", lang if lang else None) self.config.set("transcription", "min_duration", self.min_duration_spin.value()) + self.config.set("transcription", "batch_size", self.batch_size_spin.value()) self.config.set("diarization", "enabled", self.diarization_enabled.isChecked()) self.config.set("diarization", "hf_token", self.hf_token_edit.text().strip()) diff --git a/app/utils/config.py b/app/utils/config.py index db46114..40d3063 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -29,6 +29,7 @@ "language": None, "device": "cpu", "min_duration": 10, + "batch_size": 8, }, "diarization": { "enabled": True, diff --git a/pyproject.toml b/pyproject.toml index 8a06709..c0d086b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.10" # current locked versions all sit under these caps. (issue #4) dependencies = [ "comtypes>=1.2.0", - "faster-whisper>=1.0.0,<2", + "faster-whisper>=1.1.0,<2", "numpy>=1.24.0,<3", "psutil>=5.9.0", "pyannote-audio>=4.0.0,<5", diff --git a/requirements.txt b/requirements.txt index aee85aa..d8d7a70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ PyQt6>=6.6.0 sounddevice>=0.4.6 PyAudioWPatch>=0.2.12 numpy>=1.24.0,<3 -faster-whisper>=1.0.0,<2 +faster-whisper>=1.1.0,<2 pyannote.audio>=4.0.0,<5 torch>=2.0.0,<3 torchaudio>=2.0.0,<3 diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index 5dfb77d..92a8ec4 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -8,14 +8,23 @@ class _FwMocks: - """Build a mocked faster_whisper module returning given segments.""" + """Build a mocked faster_whisper module returning given segments. + + Wires both the sequential path (``WhisperModel.transcribe``) and the + batched path (``BatchedInferencePipeline(model=...).transcribe``). + """ def __init__(self, segments=(), duration=5.0): + segs = list(segments) self.module = MagicMock() self.model = MagicMock() self.module.WhisperModel.return_value = self.model info = MagicMock(language="en", duration=duration) - self.model.transcribe.return_value = (iter(list(segments)), info) + self.model.transcribe.return_value = (iter(segs), info) + # Batched pipeline: BatchedInferencePipeline(model=...).transcribe(...) + self.pipeline = MagicMock() + self.module.BatchedInferencePipeline.return_value = self.pipeline + self.pipeline.transcribe.return_value = (iter(segs), info) class TestWhisperModelCache(unittest.TestCase): @@ -41,12 +50,13 @@ def test_different_params_create_new_model(self): class TestRunSegmentMapping(unittest.TestCase): def _run_worker(self, segments): + # batch_size=1 exercises the classic sequential path (model.transcribe). fw = _FwMocks(segments=segments) with patch.dict(sys.modules, {"faster_whisper": fw.module}): import app.transcription.transcriber as tr tr._MODEL_CACHE.clear() worker = tr.TranscriptionWorker( - "a.wav", model_size="base", device="cpu" + "a.wav", model_size="base", device="cpu", batch_size=1 ) results = [] worker.finished.connect(results.append) @@ -67,6 +77,47 @@ def test_word_timestamps_not_requested(self): self.assertNotIn("word_timestamps", kwargs) +class TestBatchedInference(unittest.TestCase): + """batch_size selects BatchedInferencePipeline (>1) vs the sequential path (1).""" + + def _run(self, batch_size, segments=()): + fw = _FwMocks(segments=segments) + with patch.dict(sys.modules, {"faster_whisper": fw.module}): + import app.transcription.transcriber as tr + tr._MODEL_CACHE.clear() + worker = tr.TranscriptionWorker( + "a.wav", model_size="base", device="cpu", batch_size=batch_size + ) + results = [] + worker.finished.connect(results.append) + worker.run() + return results, fw + + def test_batched_path_used_when_batch_size_gt_1(self): + _, fw = self._run(8) + fw.module.BatchedInferencePipeline.assert_called_once_with(model=fw.model) + self.assertEqual(fw.pipeline.transcribe.call_args.kwargs.get("batch_size"), 8) + fw.model.transcribe.assert_not_called() + + def test_batched_transcribe_enables_vad(self): + _, fw = self._run(4) + self.assertTrue(fw.pipeline.transcribe.call_args.kwargs.get("vad_filter")) + + def test_sequential_path_when_batch_size_1(self): + _, fw = self._run(1) + fw.module.BatchedInferencePipeline.assert_not_called() + fw.model.transcribe.assert_called_once() + + def test_batched_segments_mapped_to_result(self): + seg = MagicMock(start=0.0, end=2.0, text=" hi ", avg_logprob=-0.2) + results, _ = self._run(8, segments=[seg]) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].segments[0].text, "hi") + self.assertAlmostEqual( + results[0].segments[0].confidence, math.exp(-0.2), places=5 + ) + + class TestTranscriptSegment(unittest.TestCase): def test_to_dict_without_original_text(self): From 44f9ac0120281cdf26eec66146ea1c58295a6e50 Mon Sep 17 00:00:00 2001 From: Suijiku <130406223+Suijiku@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:16:36 -0500 Subject: [PATCH 2/2] feat: sync uv.lock with faster-whisper>=1.1.0 floor Regenerated after bumping the requirement floor for BatchedInferencePipeline. Resolved version is unchanged (1.2.1); only the recorded specifier updates. --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index f166fbf..0e4ab35 100644 --- a/uv.lock +++ b/uv.lock @@ -5247,7 +5247,7 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'all-ai'", specifier = ">=0.40.0" }, { name = "anthropic", marker = "extra == 'claude'", specifier = ">=0.40.0" }, { name = "comtypes", specifier = ">=1.2.0" }, - { name = "faster-whisper", specifier = ">=1.0.0,<2" }, + { name = "faster-whisper", specifier = ">=1.1.0,<2" }, { name = "google-generativeai", marker = "extra == 'all-ai'", specifier = ">=0.8.0" }, { name = "google-generativeai", marker = "extra == 'gemini'", specifier = ">=0.8.0" }, { name = "llama-cpp-python", marker = "extra == 'all-ai'", specifier = ">=0.3.0" },