1562 fix stop kill (#1568)

This commit is contained in:
Raivis Dejus 2026-07-25 18:36:04 +03:00 • committed by GitHub
parent 443a4f2271
commit cb0b0089a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 996 additions and 23 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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()

2
uv.lock generated
View file

@ -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" },