From cb0b0089a1773e4e9d6a787c139f1d988872e63b Mon Sep 17 00:00:00 2001 From: Raivis Dejus Date: Sat, 25 Jul 2026 18:36:04 +0300 Subject: [PATCH] 1562 fix stop kill (#1568) --- buzz/transcriber/whisper_file_transcriber.py | 75 +++- docs/docs/faq.md | 3 +- pyproject.toml | 1 + .../whisper_file_transcriber_test.py | 127 ++++++- .../recording_transcriber_widget_test.py | 191 ++++++++++ .../speaker_identification_widget_test.py | 295 +++++++++++++++- ...scription_viewer_widget_additional_test.py | 325 ++++++++++++++++++ uv.lock | 2 + 8 files changed, 996 insertions(+), 23 deletions(-) diff --git a/buzz/transcriber/whisper_file_transcriber.py b/buzz/transcriber/whisper_file_transcriber.py index 3ef7231a..192435ea 100644 --- a/buzz/transcriber/whisper_file_transcriber.py +++ b/buzz/transcriber/whisper_file_transcriber.py @@ -18,6 +18,7 @@ from threading import Thread from typing import Optional, List import tqdm +import psutil from PyQt6.QtCore import QObject from buzz import whisper_audio @@ -37,6 +38,49 @@ from stable_whisper import WhisperResult PROGRESS_REGEX = re.compile(r"\d+(\.\d+)?%") +def terminate_child_processes(pid: int, timeout: float = 5.0) -> None: + """Terminate every descendant process of ``pid`` (but not ``pid`` itself). + + For whisper.cpp the actual work runs in a ``whisper-cli`` subprocess spawned + by our multiprocessing worker. ``multiprocessing.Process.terminate()`` only + signals the worker, leaving that CLI orphaned and still consuming CPU/GPU + after the user presses Stop or closes the app. This kills those descendants. + + Crucially it does *not* touch ``pid`` itself: the worker is a direct child + of the app process and must be reaped via ``multiprocessing`` (join). Calling + ``psutil.wait_procs`` on it here would steal the ``waitpid`` reap and leave + ``Process.is_alive()`` stuck reporting True. The descendants are grandchildren + of the app, so psutil only polls them and no such race occurs. + """ + try: + parent = psutil.Process(pid) + except (psutil.NoSuchProcess, ValueError): + return + + # Snapshot the tree before anything gets reparented by the worker exiting. + try: + descendants = parent.children(recursive=True) + except (psutil.NoSuchProcess, psutil.AccessDenied): + return + + # Ask each descendant to exit (SIGTERM / TerminateProcess). + for proc in descendants: + try: + proc.terminate() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + _, alive = psutil.wait_procs(descendants, timeout=timeout) + + # Force-kill whatever ignored the polite request. + for proc in alive: + try: + proc.kill() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + psutil.wait_procs(alive, timeout=timeout) + + def check_file_has_audio_stream(file_path: str) -> None: """Check if a media file has at least one audio stream. @@ -404,19 +448,17 @@ class WhisperFileTranscriber(FileTranscriber): self.stopped = True if self.started_process: + # Kill the whisper-cli subprocess the worker spawned first. The + # worker's own terminate() below does not reach it, so it would + # otherwise keep running (orphaned) after Stop / app close. + if self.current_process.pid is not None: + terminate_child_processes(self.current_process.pid) + + # Terminate the worker itself; it is reaped via join() below. self.current_process.terminate() - if self.read_line_thread and self.read_line_thread.is_alive(): - self.read_line_thread.join(timeout=5) - if self.read_line_thread.is_alive(): - logging.warning("Read line thread still alive after 5s") - - self.current_process.join(timeout=10) - if self.current_process.is_alive(): - logging.warning("Process didn't terminate gracefully, force killing") - self.current_process.kill() - self.current_process.join(timeout=5) - + # Close the pipes to unblock the read_line thread, which is + # otherwise stuck in recv() until it gets EOF / an OSError. try: if hasattr(self, 'send_pipe') and self.send_pipe: self.send_pipe.close() @@ -429,6 +471,17 @@ class WhisperFileTranscriber(FileTranscriber): except Exception as e: logging.debug(f"Error closing recv_pipe: {e}") + if self.read_line_thread and self.read_line_thread.is_alive(): + self.read_line_thread.join(timeout=5) + if self.read_line_thread.is_alive(): + logging.warning("Read line thread still alive after 5s") + + self.current_process.join(timeout=10) + if self.current_process.is_alive(): + logging.warning("Process didn't terminate gracefully, force killing") + self.current_process.kill() + self.current_process.join(timeout=5) + def read_line(self, pipe: Connection): while True: try: diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 5f35a833..1c3d6f60 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -39,7 +39,8 @@ In addition to choosing an appropriate model size you also can choose whisper ty - **Whisper** is initial OpenAI implementation, it is accurate but slow and requires a lot of RAM. - **Faster Whisper** is an optimized implementation, it is orders of magnitude faster than regular Whisper and requires less RAM. Use this option if you have an Nvidia GPU with at least 6GB of VRAM. - **Whisper.cpp** is optimized C++ implementation, it quite fast and efficient and will use any brand of GPU. Whisper.cpp is capable of running real time transcription even on a modern laptop with integrated GPU. It can also run on CPU only. Use this option if you do not have Nvidia GPU. This is best option to us on Macs. -- **HuggingFace** option is a `Transformers` implementation and is good in that it supports wide range of custom models that may be optimized for a particular language. This option also supports [MMS](https://ai.meta.com/blog/multilingual-model-speech-recognition/) family of models from Meta AI that support over 1000 of worlds languages as well as [PEFT](https://github.com/huggingface/peft) adjustments to Whisper models. +- **HuggingFace** option is a `Transformers` implementation and is good in that it supports wide range of custom models that may be optimized for a particular language. + - Since version `1.4.0` This option also supports [MMS](https://ai.meta.com/blog/multilingual-model-speech-recognition/) family of models from Meta AI that support over 1000 of worlds languages as well as [PEFT](https://github.com/huggingface/peft) adjustments to Whisper models. - Since version `1.5.0` [Parakeet](https://huggingface.co/models?sort=trending&search=parakeet), [Qwen3-ASR](https://huggingface.co/models?sort=trending&search=qwen+asr+hf) and [VibeVoice ASR](https://huggingface.co/models?sort=trending&search=vibevoice+hf+) models are also supported. Models with `.En` in their names are for English and will not work for other languages. diff --git a/pyproject.toml b/pyproject.toml index ce9507ea..636dc453 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dependencies = [ "museval>=0.4.1,<0.5", "mypy>=1.15.0,<2", "openunmix>=1.3.0,<2", + "psutil>=5.9.0,<8", "pyyaml>=6.0.2,<7", "submitit>=1.5.2,<2", "tqdm>=4.67.1,<5", diff --git a/tests/transcriber/whisper_file_transcriber_test.py b/tests/transcriber/whisper_file_transcriber_test.py index 2cf34b0b..af899986 100644 --- a/tests/transcriber/whisper_file_transcriber_test.py +++ b/tests/transcriber/whisper_file_transcriber_test.py @@ -1,13 +1,18 @@ import glob import logging +import multiprocessing import os import platform import shutil +import subprocess +import sys import tempfile import time +from threading import Thread from typing import List from unittest.mock import Mock +import psutil import pytest from pytestqt.qtbot import QtBot @@ -24,12 +29,46 @@ from buzz.transcriber.transcriber import ( from buzz.transcriber.whisper_file_transcriber import ( WhisperFileTranscriber, check_file_has_audio_stream, + terminate_child_processes, PROGRESS_REGEX, ) from tests.audio import test_audio_path from tests.model_loader import get_model_path +def _spawn_grandchild_worker(pipe): + """Multiprocessing target that mirrors the whisper.cpp process tree. + + Spawns a long-lived subprocess (the stand-in for ``whisper-cli``), reports + its pid back to the parent, then blocks waiting on it. This gives us a + three-level tree (test -> worker process -> subprocess) to verify that + ``terminate_child_processes`` reaps grandchildren, not just the direct child. + """ + proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"]) + pipe.send(proc.pid) + pipe.close() + proc.wait() + + +def _is_dead_or_zombie(proc: psutil.Process) -> bool: + """True if the process is gone, or a not-yet-reaped zombie.""" + try: + if not proc.is_running(): + return True + return proc.status() == psutil.STATUS_ZOMBIE + except psutil.NoSuchProcess: + return True + + +def _wait_until(predicate, timeout: float = 15.0, interval: float = 0.1) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(interval) + return predicate() + + class TestCheckFileHasAudioStream: def test_valid_audio_file(self): # Should not raise exception for valid audio file @@ -74,6 +113,53 @@ class TestProgressRegex: assert percentage == 85 +class TestTerminateChildProcesses: + def test_kills_grandchild_subprocess(self): + """The whisper-cli stand-in (a grandchild) must be killed, not orphaned. + + Reproduces the whisper.cpp shape: a multiprocessing worker that spawns + a long-lived subprocess. terminate_child_processes must kill that + subprocess while leaving the worker for its owner to reap. + """ + recv_pipe, send_pipe = multiprocessing.Pipe(duplex=False) + worker = multiprocessing.Process( + target=_spawn_grandchild_worker, args=(send_pipe,) + ) + worker.start() + # Parent doesn't send; close its copy so the pipe isn't kept open. + send_pipe.close() + + # The worker reports the grandchild pid once the subprocess is up. + grandchild_pid = recv_pipe.recv() + recv_pipe.close() + + worker_proc = psutil.Process(worker.pid) + grandchild_proc = psutil.Process(grandchild_pid) + assert worker_proc.is_running() + assert grandchild_proc.is_running() + # Sanity check the tree is actually nested two levels deep. + assert grandchild_pid in { + child.pid for child in worker_proc.children(recursive=True) + } + + terminate_child_processes(worker.pid) + + # The grandchild must be gone... + assert _wait_until(lambda: _is_dead_or_zombie(grandchild_proc)), ( + "whisper-cli stand-in subprocess was orphaned instead of killed" + ) + + # ...and the worker must still be reapable via multiprocessing (i.e. + # terminate_child_processes must not have stolen its waitpid()). + worker.terminate() + worker.join(timeout=10) + assert not worker.is_alive() + + def test_missing_pid_is_noop(self): + # A pid that cannot be a live process must not raise. + terminate_child_processes(-1) + + class TestWhisperFileTranscriber: @pytest.mark.parametrize( "file_path,output_format,expected_file_path", @@ -393,7 +479,6 @@ class TestWhisperFileTranscriber: transcriber.stop() time.sleep(3) - @pytest.mark.skip() def test_transcribe_stop(self): output_file_path = os.path.join(tempfile.gettempdir(), "whisper.txt") if os.path.exists(output_file_path): @@ -421,10 +506,46 @@ class TestWhisperFileTranscriber: file_path=test_audio_path, ) ) - transcriber.run() - time.sleep(1) + + # run() blocks until transcription finishes, so drive it from a thread + # and stop it mid-flight from the test thread. + run_thread = Thread(target=transcriber.run, daemon=True) + run_thread.start() + + # Wait until the whisper.cpp worker process AND its whisper-cli + # subprocess (grandchild) are actually up. + def worker_tree_is_up() -> bool: + if not transcriber.started_process: + return False + pid = transcriber.current_process.pid + if pid is None: + return False + try: + return len(psutil.Process(pid).children(recursive=True)) > 0 + except psutil.NoSuchProcess: + return False + + assert _wait_until(worker_tree_is_up, timeout=60), ( + "whisper.cpp worker/subprocess did not start" + ) + + worker_pid = transcriber.current_process.pid + worker_proc = psutil.Process(worker_pid) + descendants = worker_proc.children(recursive=True) + assert descendants, "whisper-cli subprocess did not start" + transcriber.stop() + # run() must return promptly and the whole process tree must be gone. + run_thread.join(timeout=30) + assert not run_thread.is_alive(), "transcriber.run() did not return after stop()" + + assert _wait_until(lambda: _is_dead_or_zombie(worker_proc)) + for child in descendants: + assert _wait_until(lambda child=child: _is_dead_or_zombie(child)), ( + f"whisper-cli subprocess {child.pid} still running after stop()" + ) + # Assert that file was not created assert os.path.isfile(output_file_path) is False diff --git a/tests/widgets/recording_transcriber_widget_test.py b/tests/widgets/recording_transcriber_widget_test.py index 15822be5..b1077682 100644 --- a/tests/widgets/recording_transcriber_widget_test.py +++ b/tests/widgets/recording_transcriber_widget_test.py @@ -1457,3 +1457,194 @@ class TestPresentationTranslationSync: widget.translation_text_box.setPlainText("Translated content") widget.on_show_presentation_clicked() assert "Translated content" in widget.presentation_window._current_translation + + +class TestOnNextTranslationExport: + """CSV/TXT export of translations, mirroring the transcript export tests.""" + + @pytest.mark.timeout(60) + def test_append_below_csv_appends_columns(self, qtbot): + import csv + with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile( + suffix=".csv", delete=False, mode="w" + ) as f: + export_path = f.name + + try: + widget.transcriber_mode = RecordingTranscriberMode.APPEND_BELOW + widget.export_enabled = True + widget.translation_export_file = export_path + widget.export_file_type = "csv" + widget.on_next_translation("bonjour") + widget.on_next_translation("monde") + + with open(export_path, newline="", encoding="utf-8-sig") as f: + rows = [r for r in csv.reader(f) if r] + assert rows[0] == ["bonjour", "monde"] + finally: + os.unlink(export_path) + + @pytest.mark.timeout(60) + def test_append_above_csv_prepends_columns(self, qtbot): + import csv + with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile( + suffix=".csv", delete=False, mode="w" + ) as f: + export_path = f.name + + try: + widget.transcriber_mode = RecordingTranscriberMode.APPEND_ABOVE + widget.export_enabled = True + widget.translation_export_file = export_path + widget.export_file_type = "csv" + widget.on_next_translation("first") + widget.on_next_translation("second") + + with open(export_path, newline="", encoding="utf-8-sig") as f: + rows = [r for r in csv.reader(f) if r] + assert rows[0] == ["second", "first"] + finally: + os.unlink(export_path) + + @pytest.mark.timeout(60) + def test_append_above_csv_respects_max_entries(self, qtbot): + import csv + with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile( + suffix=".csv", delete=False, mode="w" + ) as f: + export_path = f.name + + try: + widget.transcriber_mode = RecordingTranscriberMode.APPEND_ABOVE + widget.export_enabled = True + widget.translation_export_file = export_path + widget.export_file_type = "csv" + widget.export_max_entries = 2 + widget.on_next_translation("first") + widget.on_next_translation("second") + widget.on_next_translation("third") + + with open(export_path, newline="", encoding="utf-8-sig") as f: + rows = [r for r in csv.reader(f) if r] + assert rows[0] == ["third", "second"] + finally: + os.unlink(export_path) + + @pytest.mark.timeout(60) + def test_append_below_txt_writes_translation(self, qtbot): + with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile( + suffix=".txt", delete=False, mode="w" + ) as f: + export_path = f.name + + try: + widget.transcription_options.line_separator = "\n" + widget.transcriber_mode = RecordingTranscriberMode.APPEND_BELOW + widget.export_enabled = True + widget.translation_export_file = export_path + widget.export_file_type = "txt" + widget.on_next_translation("bonjour") + widget.on_next_translation("monde") + + with open(export_path) as f: + content = f.read() + assert "bonjour" in content + assert "monde" in content + finally: + os.unlink(export_path) + + +class TestAppendAndCorrectCsvExport: + """APPEND_AND_CORRECT rewrites the CSV with the whole merged text as one entry.""" + + @pytest.mark.timeout(60) + def test_transcript_merge_writes_single_csv_entry(self, qtbot): + import csv + with _widget_ctx(qtbot) as widget, tempfile.NamedTemporaryFile( + suffix=".csv", delete=False, mode="w" + ) as f: + export_path = f.name + + try: + widget.transcriber_mode = RecordingTranscriberMode.APPEND_AND_CORRECT + widget.hide_unconfirmed = False + widget.export_enabled = True + widget.export_file_type = "csv" + widget.transcript_export_file = export_path + widget.on_next_transcription("Hello world.") + widget.on_next_transcription("world. Goodbye.") + + with open(export_path, newline="", encoding="utf-8-sig") as f: + rows = [r for r in csv.reader(f) if r] + assert len(rows) == 1 + assert len(rows[0]) == 1 + assert "Hello" in rows[0][0] + assert "Goodbye" in rows[0][0] + finally: + os.unlink(export_path) + + +class TestRecordingModeAndHideUnconfirmed: + @pytest.mark.timeout(60) + def test_on_recording_mode_changed_updates_mode(self, qtbot): + with _widget_ctx(qtbot) as widget: + widget.on_recording_mode_changed(RecordingTranscriberMode.APPEND_ABOVE) + assert widget.transcriber_mode == RecordingTranscriberMode.APPEND_ABOVE + + @pytest.mark.timeout(60) + def test_on_hide_unconfirmed_changed_updates_flag(self, qtbot): + with _widget_ctx(qtbot) as widget: + widget.on_hide_unconfirmed_changed(False) + assert widget.hide_unconfirmed is False + widget.on_hide_unconfirmed_changed(True) + assert widget.hide_unconfirmed is True + + +class TestOnCopyTranscriptFailurePaths: + @pytest.mark.timeout(60) + def test_no_qapplication_instance_shows_copy_failed(self, qtbot): + with _widget_ctx(qtbot) as widget: + widget.transcription_text_box.setPlainText("some text") + with patch("buzz.widgets.recording_transcriber_widget.QApplication.instance", + return_value=None): + widget.on_copy_transcript_clicked() + assert widget.copy_transcript_button.text() == _("Copy failed") + + @pytest.mark.timeout(60) + def test_none_clipboard_shows_copy_failed(self, qtbot): + with _widget_ctx(qtbot) as widget: + widget.transcription_text_box.setPlainText("some text") + mock_app = MagicMock() + mock_app.clipboard.return_value = None + with patch("buzz.widgets.recording_transcriber_widget.QApplication.instance", + return_value=mock_app): + widget.on_copy_transcript_clicked() + assert widget.copy_transcript_button.text() == _("Copy failed") + + @pytest.mark.timeout(60) + def test_clipboard_set_text_exception_shows_copy_failed(self, qtbot): + with _widget_ctx(qtbot) as widget: + widget.transcription_text_box.setPlainText("some text") + mock_clipboard = MagicMock() + mock_clipboard.setText.side_effect = Exception("clipboard boom") + mock_app = MagicMock() + mock_app.clipboard.return_value = mock_clipboard + with patch("buzz.widgets.recording_transcriber_widget.QApplication.instance", + return_value=mock_app): + widget.on_copy_transcript_clicked() + assert widget.copy_transcript_button.text() == _("Copy failed") + + +class TestWriteCsvExportErrorHandling: + def test_write_gives_up_after_max_retries(self, tmp_path): + path = str(tmp_path / "out.csv") + with patch("builtins.open", side_effect=PermissionError("locked")), \ + patch("time.sleep"): + # Should log and return without raising + RecordingTranscriberWidget.write_csv_export(path, "data", 0) + + def test_write_handles_oserror(self, tmp_path): + path = str(tmp_path / "out.csv") + with patch("builtins.open", side_effect=OSError("disk full")): + # Should return gracefully without raising + RecordingTranscriberWidget.write_csv_export(path, "data", 0) diff --git a/tests/widgets/speaker_identification_widget_test.py b/tests/widgets/speaker_identification_widget_test.py index 1204227c..8dc48072 100644 --- a/tests/widgets/speaker_identification_widget_test.py +++ b/tests/widgets/speaker_identification_widget_test.py @@ -349,7 +349,7 @@ class TestSpeakerIdentificationWidget: # Create mock punctuation model - raise AssertionError on first batch, then succeed mock_punct_model = MagicMock() call_count = [0] - + def predict_side_effect(batch, chunk_size): call_count[0] += 1 # Raise AssertionError on first call (first batch) @@ -357,26 +357,26 @@ class TestSpeakerIdentificationWidget: raise AssertionError("Chunk size too large") # Succeed on subsequent calls (smaller batches) return [(word.strip(), ".") for word in batch] - + mock_punct_model.predict.side_effect = predict_side_effect - + # Create words list with 201 words (enough to trigger batch processing) words_list = [f"word{i}" for i in range(201)] - + # Wrap predict method to match the expected signature def predict_wrapper(batch, chunk_size, **kwargs): return mock_punct_model.predict(batch, chunk_size=chunk_size) - + # Call the generic batch processing function result = process_in_batches( items=words_list, process_func=predict_wrapper ) - + # Verify that predict was called multiple times # First call fails, then smaller batches succeed assert mock_punct_model.predict.call_count > 1, "Should retry with smaller batches after AssertionError" - + # Verify that smaller batches were used after the error call_args_list = mock_punct_model.predict.call_args_list # After the first failed call, subsequent calls should have smaller batches @@ -384,7 +384,286 @@ class TestSpeakerIdentificationWidget: args, kwargs = call batch = args[0] assert len(batch) <= 100, f"After AssertionError, batch size should be <= 100, got {len(batch)}" - + # Verify result contains all words assert len(result) == 201, "Result should contain all words" + # ------------------------------------------------------------------ + # IdentificationWorker unit tests + # + # These exercise the worker's individual helper methods in isolation with + # mocked ML libraries, so they do NOT run any real speaker identification. + # ------------------------------------------------------------------ + + def test_worker_cancel_sets_flag(self, transcription, transcription_service): + """cancel() flips the flag and _cancel_if_requested reports it.""" + worker = IdentificationWorker(transcription, transcription_service) + + assert worker._cancel_if_requested("step 1") is False + + worker.cancel() + + assert worker._is_cancelled is True + assert worker._cancel_if_requested("step 1") is True + + def test_get_transcript_splits_on_sentence_end(self, transcription): + """get_transcript groups words into a segment when a sentence ends.""" + mock_service = MagicMock() + mock_service.get_transcription_segments.return_value = [ + MagicMock(text="Hello", start_time=0, end_time=100), + MagicMock(text="world.", start_time=100, end_time=200), + MagicMock(text="Unfinished", start_time=200, end_time=300), + ] + worker = IdentificationWorker(transcription, mock_service) + + result = worker.get_transcript(audio=None) + + assert result["language"] == transcription.language + # Only "world." ends a sentence, so exactly one segment is emitted and + # the trailing "Unfinished" word (no terminator) is dropped. + assert len(result["segments"]) == 1 + assert result["segments"][0]["text"] == "Hello world. " + assert [w["word"] for w in result["segments"][0]["words"]] == ["Hello ", "world. "] + + def test_get_transcript_data_joins_segments(self, transcription, transcription_service): + """_get_transcript_data collapses whitespace and decodes the audio.""" + import numpy as np + worker = IdentificationWorker(transcription, transcription_service) + + fake_waveform = np.zeros(10, dtype=np.float32) + with patch( + "buzz.widgets.transcription_viewer.speaker_identification_widget.faster_whisper.decode_audio", + return_value=fake_waveform, + ) as mock_decode: + language, full_transcript, waveform = worker._get_transcript_data() + + # transcription fixture has no language set, so it falls back to "en" + assert language == "en" + assert full_transcript == "Bien venue dans" + assert waveform is fake_waveform + mock_decode.assert_called_once_with(transcription.file) + + def test_setup_device_force_cpu(self, transcription, transcription_service, monkeypatch): + """_setup_device honours BUZZ_FORCE_CPU regardless of CUDA availability.""" + import torch + worker = IdentificationWorker(transcription, transcription_service) + monkeypatch.setenv("BUZZ_FORCE_CPU", "true") + + device, torch_dtype = worker._setup_device() + + assert device == "cpu" + assert torch_dtype == torch.float32 + + def test_load_alignment_model_with_retry_success(self, transcription, transcription_service): + """Model loads on the first attempt without retrying.""" + import torch + worker = IdentificationWorker(transcription, transcription_service) + worker._load_alignment_model = MagicMock(return_value=("model", "tokenizer")) + + model, tokenizer = worker._load_alignment_model_with_retry("cpu", torch.float32) + + assert (model, tokenizer) == ("model", "tokenizer") + assert worker._load_alignment_model.call_count == 1 + + def test_load_alignment_model_with_retry_recovers(self, transcription, transcription_service): + """A first failure is retried and the second attempt succeeds.""" + import torch + worker = IdentificationWorker(transcription, transcription_service) + worker._load_alignment_model = MagicMock( + side_effect=[Exception("network"), ("model", "tokenizer")] + ) + + with patch("buzz.widgets.transcription_viewer.speaker_identification_widget.time.sleep"): + model, tokenizer = worker._load_alignment_model_with_retry("cpu", torch.float32) + + assert (model, tokenizer) == ("model", "tokenizer") + assert worker._load_alignment_model.call_count == 2 + + def test_load_alignment_model_with_retry_exhausted_raises(self, transcription, transcription_service): + """After all attempts fail a RuntimeError is raised.""" + import torch + worker = IdentificationWorker(transcription, transcription_service) + worker._load_alignment_model = MagicMock(side_effect=Exception("network")) + + with patch("buzz.widgets.transcription_viewer.speaker_identification_widget.time.sleep"): + with pytest.raises(RuntimeError): + worker._load_alignment_model_with_retry("cpu", torch.float32) + + assert worker._load_alignment_model.call_count == 3 + + def test_handle_run_error_emits_signals(self, transcription, transcription_service): + """_handle_run_error emits error + an empty finished result.""" + worker = IdentificationWorker(transcription, transcription_service) + + errors = [] + finished = [] + progress = [] + worker.error.connect(errors.append) + worker.finished.connect(finished.append) + worker.progress_update.connect(progress.append) + + worker._handle_run_error(Exception("boom")) + + assert errors == ["boom"] + assert finished == [[]] + assert len(progress) == 1 + + def test_run_import_error_emits_error(self, transcription, transcription_service): + """run() surfaces a friendly error if libraries fail to import.""" + worker = IdentificationWorker(transcription, transcription_service) + worker._import_libraries = MagicMock(side_effect=ImportError("no module")) + + errors = [] + worker.error.connect(errors.append) + + worker.run() + + # The message is localized, but the underlying import error detail is + # always appended verbatim. + assert len(errors) == 1 + assert "no module" in errors[0] + + def test_map_speakers_unsupported_language_skips_punctuation(self, transcription, transcription_service): + """For an unsupported language, punctuation restoration is skipped.""" + worker = IdentificationWorker(transcription, transcription_service) + + # Wire up the helpers normally injected by _import_libraries. + worker._get_words_speaker_mapping = MagicMock(return_value=[{"word": "hi"}]) + worker._punct_model_langs = ["en"] + worker._PunctuationModel = MagicMock() + worker._get_realigned_ws_mapping_with_punctuation = MagicMock( + return_value=[{"word": "hi"}] + ) + worker._get_sentences_speaker_mapping = MagicMock(return_value=["sentence"]) + + result = worker._map_speakers_with_punctuation( + word_timestamps=[], speaker_ts=[], language="xx", + ) + + assert result == ["sentence"] + # Punctuation model must not be constructed for an unsupported language. + worker._PunctuationModel.assert_not_called() + worker._get_sentences_speaker_mapping.assert_called_once() + + # ------------------------------------------------------------------ + # SpeakerIdentificationWidget unit tests + # ------------------------------------------------------------------ + + def test_on_progress_update_invalid_format(self, qtbot: QtBot, transcription, transcription_service): + """A non-numeric progress string updates the label but not the bar.""" + widget = SpeakerIdentificationWidget( + transcription=transcription, + transcription_service=transcription_service, + ) + qtbot.addWidget(widget) + + widget.progress_bar.setValue(3) + widget.on_progress_update("Loading something") + + assert widget.progress_label.text() == "Loading something" + # Bar value is left untouched when the format is invalid + assert widget.progress_bar.value() == 3 + + widget.close() + + def test_on_speaker_preview_plays_segment(self, qtbot: QtBot, transcription, transcription_service): + """on_speaker_preview seeks and plays for a matching speaker.""" + widget = SpeakerIdentificationWidget( + transcription=transcription, + transcription_service=transcription_service, + ) + qtbot.addWidget(widget) + + widget.identification_result = [ + {'speaker': 'Speaker 0', 'start_time': 1500, 'end_time': 2000, 'text': 'Hi'}, + ] + + with patch.object(widget.player, 'setPosition') as mock_set, \ + patch.object(widget.player, 'play') as mock_play: + widget.on_speaker_preview('Speaker 0') + + mock_set.assert_called_once_with(1500) + mock_play.assert_called_once() + + assert widget.player_timer is not None + widget.player_timer.stop() + widget.close() + + def test_on_speaker_preview_no_matching_records(self, qtbot: QtBot, transcription, transcription_service): + """on_speaker_preview does nothing when the speaker has no records.""" + widget = SpeakerIdentificationWidget( + transcription=transcription, + transcription_service=transcription_service, + ) + qtbot.addWidget(widget) + + widget.identification_result = [ + {'speaker': 'Speaker 0', 'start_time': 0, 'end_time': 1000, 'text': 'Hi'}, + ] + + with patch.object(widget.player, 'play') as mock_play: + widget.on_speaker_preview('Speaker 99') + mock_play.assert_not_called() + + widget.close() + + def test_show_model_selector(self, qtbot: QtBot, transcription, transcription_service): + """_show_model_selector swaps the progress bar back for the model selector.""" + widget = SpeakerIdentificationWidget( + transcription=transcription, + transcription_service=transcription_service, + ) + qtbot.addWidget(widget) + + widget.progress_bar.setVisible(True) + widget.model_selection_widget.setVisible(False) + + widget._show_model_selector() + + assert widget.progress_bar.isHidden() + assert not widget.model_selection_widget.isHidden() + + widget.close() + + def test_cleanup_thread_when_none(self, qtbot: QtBot, transcription, transcription_service): + """_cleanup_thread is safe when there is no active thread/worker.""" + widget = SpeakerIdentificationWidget( + transcription=transcription, + transcription_service=transcription_service, + ) + qtbot.addWidget(widget) + + widget.thread = None + widget.worker = None + + # Should not raise + widget._cleanup_thread() + + assert widget.thread is None + assert widget.worker is None + + widget.close() + + def test_identify_uses_selected_diarizer(self, qtbot: QtBot, transcription, transcription_service): + """The Sortformer radio selects the sortformer diarizer for the worker.""" + widget = SpeakerIdentificationWidget( + transcription=transcription, + transcription_service=transcription_service, + ) + qtbot.addWidget(widget) + + widget.sortformer_radio.setChecked(True) + + from PyQt6.QtCore import QThread as RealQThread + mock_thread = MagicMock(spec=RealQThread) + mock_thread.started = MagicMock() + + with patch.object(widget, '_cleanup_thread'), \ + patch('buzz.widgets.transcription_viewer.speaker_identification_widget.QThread', return_value=mock_thread), \ + patch.object(IdentificationWorker, 'moveToThread'): + widget.on_identify_button_clicked() + + assert widget.worker.diarizer == "sortformer" + + widget.close() + diff --git a/tests/widgets/transcription_viewer/transcription_viewer_widget_additional_test.py b/tests/widgets/transcription_viewer/transcription_viewer_widget_additional_test.py index bf996203..b5e10508 100644 --- a/tests/widgets/transcription_viewer/transcription_viewer_widget_additional_test.py +++ b/tests/widgets/transcription_viewer/transcription_viewer_widget_additional_test.py @@ -994,3 +994,328 @@ class TestTranscriptionViewerWidgetAdditional: assert widget.current_media_player == widget.video_player widget.close() + + # ------------------------------------------------------------------ + # Search navigation + # ------------------------------------------------------------------ + + def test_on_search_text_changed_debounces_long_text(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """Typing >= 2 chars starts the debounce timer and shows the search bar.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.on_search_text_changed("seg") + + assert widget.search_text == "seg" + assert widget.search_debounce_timer.isActive() + # show() was requested on the frame (not hidden explicitly) + assert not widget.search_frame.isHidden() + + widget.close() + + def test_on_search_text_changed_empty_clears(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """Clearing the input stops the timer and resets search state.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.search_results = [("table", 0, None)] + widget.on_search_text_changed("") + + assert widget.search_text == "" + assert not widget.search_debounce_timer.isActive() + assert widget.search_results == [] + + widget.close() + + def test_search_next_wraps_around(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """search_next advances the index and wraps back to the start.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.search_results = [("table", 0, None), ("table", 1, None)] + widget.current_search_index = 0 + + widget.search_next() + assert widget.current_search_index == 1 + + # Wrap around back to 0 + widget.search_next() + assert widget.current_search_index == 0 + + widget.close() + + def test_search_previous_wraps_around(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """search_previous decrements the index and wraps to the end.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.search_results = [("table", 0, None), ("table", 1, None)] + widget.current_search_index = 0 + + widget.search_previous() + assert widget.current_search_index == 1 + + widget.close() + + def test_search_next_if_results_noop_when_empty(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """The *_if_results helpers do nothing when there are no results.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.search_results = [] + widget.current_search_index = 0 + + widget.search_next_if_results() + widget.search_previous_if_results() + + assert widget.current_search_index == 0 + + widget.close() + + def test_clear_search_resets_state(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """clear_search empties results and disables navigation buttons.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.search_text = "segment" + widget.search_results = [("table", 0, None)] + widget.search_prev_button.setEnabled(True) + widget.search_next_button.setEnabled(True) + + widget.clear_search() + + assert widget.search_text == "" + assert widget.search_results == [] + assert not widget.search_prev_button.isEnabled() + assert not widget.search_next_button.isEnabled() + + widget.close() + + def test_hide_search_bar(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """hide_search_bar hides the frame and syncs the find button state.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + widget.show() + + widget.show_search_bar() + assert widget.search_frame.isVisible() + + widget.hide_search_bar() + + assert not widget.search_frame.isVisible() + assert not widget.find_button.isChecked() + assert widget.find_widget_visible is False + + widget.close() + + def test_focus_search_input_toggles(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """focus_search_input shows the bar when hidden and hides it when shown.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + widget.show() + + # Hidden -> shown + widget.search_frame.hide() + widget.focus_search_input() + assert widget.search_frame.isVisible() + assert widget.find_widget_visible is True + + # Shown -> hidden + widget.focus_search_input() + assert not widget.search_frame.isVisible() + + widget.close() + + def test_search_100_plus_matches_label(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """update_search_ui shows a "100+" label when the cap is hit.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.search_results = [("table", i, None) for i in range(100)] + widget.update_search_ui() + + assert "100+" in widget.search_results_label.text() + + widget.close() + + # ------------------------------------------------------------------ + # View mode / segment selection + # ------------------------------------------------------------------ + + def test_on_view_mode_changed(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """on_view_mode_changed updates the mode and refreshes the view.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + widget.show() + + widget.on_view_mode_changed(ViewMode.TEXT) + + assert widget.view_mode == ViewMode.TEXT + assert widget.text_display_box.isVisible() + + widget.close() + + def test_on_segment_selected(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """on_segment_selected stores the segment and shows its text.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + widget.show() + + record = widget.table_widget.model().record(0) + widget.on_segment_selected(record) + + # The highlight path may re-emit selection with a fresh QSqlRecord, so + # compare by id value rather than object identity. + assert widget.currently_selected_segment.value("id") == record.value("id") + assert not widget.current_segment_frame.isHidden() + assert widget.current_segment_text.text() == record.value("text") + + widget.close() + + def test_on_audio_player_position_ms_changed_updates_text(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """Position changes update the current-segment text to the active segment.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + widget.show() + + # Position 600ms falls inside the second segment (500-1000, "Second segment") + widget.on_audio_player_position_ms_changed(600) + + assert widget.current_segment_text.text() == "Second segment" + + widget.close() + + def test_on_audio_player_position_ms_changed_no_segment(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """A position outside all segments is a no-op (returns early).""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + before = widget.current_segment_text.text() + widget.on_audio_player_position_ms_changed(999999) + + assert widget.current_segment_text.text() == before + + widget.close() + + # ------------------------------------------------------------------ + # Loop toggle + # ------------------------------------------------------------------ + + def test_on_loop_toggle_changed_enable_with_selection(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """Enabling looping with a selected segment sets a loop range.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.currently_selected_segment = widget.table_widget.model().record(0) + + with patch.object(widget.current_media_player, 'set_range') as mock_range: + widget.on_loop_toggle_changed(True) + + assert widget.segment_looping_enabled is True + assert mock_range.called + + widget.close() + + def test_on_loop_toggle_changed_disable_clears_range(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """Disabling looping clears any existing loop range.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + with patch.object(widget.current_media_player, 'clear_range') as mock_clear: + widget.on_loop_toggle_changed(False) + + assert widget.segment_looping_enabled is False + mock_clear.assert_called_once() + + widget.close() + + # ------------------------------------------------------------------ + # Dialog launchers + # ------------------------------------------------------------------ + + def test_on_resize_button_clicked(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """The resize button constructs and shows the resizer dialog.""" + updated_signal = MagicMock() + widget = TranscriptionViewerWidget( + transcription, transcription_service, shortcuts, + transcriptions_updated_signal=updated_signal, + ) + qtbot.add_widget(widget) + + with patch( + 'buzz.widgets.transcription_viewer.transcription_viewer_widget.TranscriptionResizerWidget' + ) as mock_resizer: + widget.on_resize_button_clicked() + + mock_resizer.assert_called_once() + widget.transcription_resizer_dialog.show.assert_called_once() + + widget.close() + + def test_on_speaker_identification_button_clicked(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """The identify-speakers button constructs and shows the dialog.""" + import platform as _platform + if _platform.system() == "Darwin" and _platform.machine() == "x86_64": + pytest.skip("Speaker identification unavailable on Intel Mac") + + updated_signal = MagicMock() + widget = TranscriptionViewerWidget( + transcription, transcription_service, shortcuts, + transcriptions_updated_signal=updated_signal, + ) + qtbot.add_widget(widget) + + with patch( + 'buzz.widgets.transcription_viewer.transcription_viewer_widget.SpeakerIdentificationWidget' + ) as mock_dialog: + widget.on_speaker_identification_button_clicked() + + mock_dialog.assert_called_once() + widget.speaker_identification_dialog.show.assert_called_once() + + widget.close() + + # ------------------------------------------------------------------ + # Splitter sizes persistence + # ------------------------------------------------------------------ + + def test_save_and_load_splitter_sizes(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """Splitter sizes round-trip through settings without error.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.media_splitter.setSizes([700, 300]) + widget.save_splitter_sizes() + widget.load_splitter_sizes() + + # Sizes are applied back onto the splitter + assert sum(widget.media_splitter.sizes()) > 0 + + widget.close() + + def test_on_splitter_moved_schedules_save(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """Moving the splitter schedules a deferred save.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + with patch( + 'buzz.widgets.transcription_viewer.transcription_viewer_widget.QTimer.singleShot' + ) as mock_single_shot: + widget.on_splitter_moved(100, 0) + mock_single_shot.assert_called_once() + + widget.close() + + def test_close_event_closes_child_dialogs(self, qtbot: QtBot, transcription, transcription_service, shortcuts): + """closeEvent closes any open child dialogs.""" + widget = TranscriptionViewerWidget(transcription, transcription_service, shortcuts) + qtbot.add_widget(widget) + + widget.transcription_resizer_dialog = MagicMock() + widget.speaker_identification_dialog = MagicMock() + + from PyQt6.QtGui import QCloseEvent + widget.closeEvent(QCloseEvent()) + + widget.transcription_resizer_dialog.close.assert_called_once() + widget.speaker_identification_dialog.close.assert_called_once() diff --git a/uv.lock b/uv.lock index 8c59ba83..bc9011c3 100644 --- a/uv.lock +++ b/uv.lock @@ -314,6 +314,7 @@ dependencies = [ { name = "platformdirs" }, { name = "polib" }, { name = "posthog" }, + { name = "psutil" }, { name = "pyqt6" }, { name = "pyqt6-qt6" }, { name = "pyqt6-sip" }, @@ -408,6 +409,7 @@ requires-dist = [ { name = "platformdirs", specifier = ">=4.2.1,<5" }, { name = "polib", specifier = ">=1.2.0,<2" }, { name = "posthog", specifier = ">=3.23.0,<4" }, + { name = "psutil", specifier = ">=5.9.0,<8" }, { name = "pyqt6", specifier = "==6.9.1" }, { name = "pyqt6-qt6", specifier = "==6.9.1" }, { name = "pyqt6-sip", specifier = "==13.10.2" },