Prevent system sleep while transcriptions are queued (#1593)

Co-authored-by: Raivis Dejus <raivisd@scandiweb.com>
This commit is contained in:
Zhang Wenhao 2026-08-23 13:54:40 +08:00 • committed by GitHub
parent 646c61bed8
commit aaf98634cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 611 additions and 18 deletions

View file

@ -6,9 +6,11 @@ import ssl
import sys
from pathlib import Path
from typing import Optional, Tuple, List, Set
from uuid import UUID
from uuid import UUID, uuid4
from dataclasses import dataclass
from buzz.sleep_inhibitor import TaskActivity
# Fix SSL certificate verification for bundled applications (macOS, Windows)
# This must be done before importing demucs which uses torch.hub with urllib
try:
@ -101,10 +103,17 @@ from buzz.transcriber.whisper_file_transcriber import WhisperFileTranscriber
@dataclass
class _CurrentTranscription:
task: Optional[FileTranscriptionTask] = None
activity_token: Optional[UUID] = None
transcriber: Optional[FileTranscriber] = None
transcriber_thread: Optional[QThread] = None
@dataclass(frozen=True)
class _QueuedTranscription:
task: FileTranscriptionTask
activity_token: UUID
class FileTranscriberQueueWorker(QObject):
tasks_queue: multiprocessing.Queue
@ -113,6 +122,7 @@ class FileTranscriberQueueWorker(QObject):
task_download_progress = pyqtSignal(FileTranscriptionTask, float)
task_completed = pyqtSignal(FileTranscriptionTask, list)
task_error = pyqtSignal(FileTranscriptionTask, str)
queue_busy_changed = pyqtSignal(bool)
completed = pyqtSignal()
trigger_run = pyqtSignal()
@ -125,6 +135,7 @@ class FileTranscriberQueueWorker(QObject):
self.speech_path = None
self.speech_extractor_process = None
self.is_running = False
self.task_activity = TaskActivity(self.queue_busy_changed.emit)
# Assigned by MainWindow after construction. Duck-typed to avoid an
# import cycle with the plugins package.
self.plugin_manager = None
@ -152,6 +163,7 @@ class FileTranscriberQueueWorker(QObject):
status = self._setup_speech_extraction()
if status == "error":
self.is_running = False
self._on_task_finished()
return
if not self._run_plugins():
@ -170,10 +182,18 @@ class FileTranscriberQueueWorker(QObject):
def _get_next_task(self) -> bool:
while True:
self.current.task = self.tasks_queue.get()
if self.current.task is None:
queued = self.tasks_queue.get()
if queued is None:
return False
if isinstance(queued, _QueuedTranscription):
self.current.task = queued.task
self.current.activity_token = queued.activity_token
else:
self.current.task = queued
self.current.activity_token = None
if self.current.task.uid in self.canceled_tasks:
if self.current.activity_token is not None:
self.task_activity.finish(self.current.activity_token)
continue
return True
@ -267,8 +287,14 @@ class FileTranscriberQueueWorker(QObject):
self.current.transcriber.completed.connect(self.on_task_completed)
self.current.transcriber.error.connect(lambda: self._on_task_finished())
self.current.transcriber.completed.connect(lambda: self._on_task_finished())
task = self.current.task
activity_token = self.current.activity_token
self.current.transcriber.error.connect(
lambda: self._on_task_finished(task, activity_token)
)
self.current.transcriber.completed.connect(
lambda: self._on_task_finished(task, activity_token)
)
self.task_started.emit(self.current.task)
self.current.transcriber_thread.start()
@ -349,8 +375,20 @@ class FileTranscriberQueueWorker(QObject):
process.kill()
process.join(timeout=5)
def _on_task_finished(self):
def _on_task_finished(
self,
task: Optional[FileTranscriptionTask] = None,
activity_token: Optional[UUID] = None,
):
"""Called when a task completes or errors, resets state and triggers next run"""
if task is None:
task = self.current.task
activity_token = self.current.activity_token
if activity_token is not None:
self.task_activity.finish(activity_token)
if task is not self.current.task or activity_token != self.current.activity_token:
return
self.current.activity_token = None
self.is_running = False
# Use signal to avoid blocking in signal handler context
self.trigger_run.emit()
@ -360,7 +398,9 @@ class FileTranscriberQueueWorker(QObject):
if task.uid in self.canceled_tasks:
self.canceled_tasks.remove(task.uid)
self.tasks_queue.put(task)
activity_token = uuid4()
self.task_activity.add(activity_token)
self.tasks_queue.put(_QueuedTranscription(task, activity_token))
# If the worker is not currently running, trigger it to start processing
# Use signal to avoid blocking the main thread
if not self.is_running:
@ -370,15 +410,21 @@ class FileTranscriberQueueWorker(QObject):
self.canceled_tasks.add(task_id)
if self.current.task is not None and self.current.task.uid == task_id:
task = self.current.task
activity_token = self.current.activity_token
transcriber = self.current.transcriber
transcriber_thread = self.current.transcriber_thread
self._terminate_speech_extractor_process()
if self.current.transcriber is not None:
self.current.transcriber.stop()
if transcriber is not None:
transcriber.stop()
if self.current.transcriber_thread is not None:
if not self.current.transcriber_thread.wait(5000):
if transcriber_thread is not None:
if not transcriber_thread.wait(5000):
logging.warning("Transcriber thread did not terminate gracefully")
self.current.transcriber_thread.terminate()
transcriber_thread.terminate()
transcriber_thread.wait()
self._on_task_finished(task, activity_token)
def on_task_error(self, error: str):
if (
@ -416,6 +462,7 @@ class FileTranscriberQueueWorker(QObject):
self.speech_path = None
def stop(self):
self.task_activity.clear()
self.tasks_queue.put(None)
if self.current.transcriber is not None:
self.current.transcriber.stop()

View file

@ -2007,6 +2007,11 @@ msgstr "Desa el resum en un fitxer"
msgid "Output folder (for file)"
msgstr "Carpeta de sortida (per al fitxer)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Evita que el sistema entri en repòs mentre hi ha transcripcions a la cua"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1995,6 +1995,11 @@ msgstr "Gem resumé i en fil"
msgid "Output folder (for file)"
msgstr "Outputmappe (til fil)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Forhindr systemet i at gå i dvale, når der er transkriptioner i køen"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -2016,6 +2016,11 @@ msgstr "Zusammenfassung in einer Datei speichern"
msgid "Output folder (for file)"
msgstr "Ausgabeordner (für Datei)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Ruhezustand des Systems verhindern, solange Transkriptionen in der Warteschlange sind"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1922,6 +1922,10 @@ msgstr ""
msgid "Output folder (for file)"
msgstr ""
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -2069,6 +2069,11 @@ msgstr "Guardar resumen en un archivo"
msgid "Output folder (for file)"
msgstr "Carpeta de salida (para el archivo)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Evitar que el sistema entre en suspensión mientras haya transcripciones en cola"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -2009,6 +2009,11 @@ msgstr "Salva il riepilogo in un file"
msgid "Output folder (for file)"
msgstr "Cartella di output (per il file)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Impedisci la sospensione del sistema mentre ci sono trascrizioni in coda"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1975,6 +1975,11 @@ msgstr "要約をファイルに保存"
msgid "Output folder (for file)"
msgstr "出力フォルダ(ファイル用)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"文字起こしがキューにある間はシステムのスリープを防止する"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1985,6 +1985,11 @@ msgstr "Saglabāt failā"
msgid "Output folder (for file)"
msgstr "Izvades mape"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Neļaut sistēmai iemigt, kamēr rindā ir atpazīšanas uzdevumi"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -2005,6 +2005,11 @@ msgstr "Samenvatting in een bestand opslaan"
msgid "Output folder (for file)"
msgstr "Uitvoermap (voor bestand)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Voorkom dat het systeem in slaapstand gaat terwijl er transcripties in de wachtrij staan"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1999,6 +1999,11 @@ msgstr "Zapisz podsumowanie w pliku"
msgid "Output folder (for file)"
msgstr "Folder wyjściowy (dla pliku)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Zapobiegaj uśpieniu systemu, gdy w kolejce są transkrypcje"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1997,6 +1997,11 @@ msgstr "Salvar resumo em um arquivo"
msgid "Output folder (for file)"
msgstr "Pasta de saída (para o arquivo)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Impedir que o sistema entre em suspensão enquanto houver transcrições na fila"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -2000,6 +2000,11 @@ msgstr "Сохранить резюме в файл"
msgid "Output folder (for file)"
msgstr "Папка вывода (для файла)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Не давать системе переходить в спящий режим, пока в очереди есть транскрипции"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1994,6 +1994,11 @@ msgstr "Зберегти резюме у файл"
msgid "Output folder (for file)"
msgstr "Папка виводу (для файлу)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"Не дозволяти системі переходити в режим сну, поки в черзі є транскрипції"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1947,6 +1947,11 @@ msgstr "将摘要保存到文件"
msgid "Output folder (for file)"
msgstr "输出文件夹(用于文件)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"有转录任务排队时阻止系统睡眠"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -1948,6 +1948,11 @@ msgstr "將摘要儲存到檔案"
msgid "Output folder (for file)"
msgstr "輸出資料夾(用於檔案)"
#: buzz/widgets/preferences_dialog/general_preferences_widget.py
msgid "Prevent system sleep while transcriptions are queued"
msgstr ""
"有轉錄工作排隊時防止系統睡眠"
#: buzz/file_transcriber_queue_worker.py
msgid ""
"Speech extraction failed! Check your internet connection \\u2014 a model may "

View file

@ -87,6 +87,7 @@ class Settings:
FORCE_CPU = "force-cpu"
REDUCE_GPU_MEMORY = "reduce-gpu-memory"
PREVENT_SLEEP_WHILE_TRANSCRIBING = "prevent-sleep-while-transcribing"
LAST_UPDATE_CHECK = "update/last-check"
UPDATE_AVAILABLE_VERSION = "update/available-version"

151
buzz/sleep_inhibitor.py Normal file
View file

@ -0,0 +1,151 @@
import ctypes
import logging
import os
import platform
import subprocess
import threading
from collections import Counter
from collections.abc import Callable
from typing import Protocol
from uuid import UUID
ES_SYSTEM_REQUIRED = 0x00000001
ES_CONTINUOUS = 0x80000000
class SleepBackend(Protocol):
def acquire(self) -> None:
...
def release(self) -> None:
...
class TaskActivity:
"""Track queued and running tasks and report busy-state transitions."""
def __init__(self, on_busy_changed: Callable[[bool], None]):
self._task_counts: Counter[UUID] = Counter()
self._on_busy_changed = on_busy_changed
self._lock = threading.Lock()
def add(self, task_id: UUID) -> None:
with self._lock:
was_busy = bool(self._task_counts)
self._task_counts[task_id] += 1
if not was_busy:
self._on_busy_changed(True)
def finish(self, task_id: UUID) -> None:
with self._lock:
was_busy = bool(self._task_counts)
if self._task_counts[task_id] > 1:
self._task_counts[task_id] -= 1
else:
self._task_counts.pop(task_id, None)
if was_busy and not self._task_counts:
self._on_busy_changed(False)
def clear(self) -> None:
with self._lock:
if self._task_counts:
self._task_counts.clear()
self._on_busy_changed(False)
class WindowsSleepBackend:
def __init__(self, set_execution_state=None):
self._set_execution_state = (
set_execution_state
if set_execution_state is not None
else ctypes.windll.kernel32.SetThreadExecutionState
)
def acquire(self) -> None:
if not self._set_execution_state(ES_CONTINUOUS | ES_SYSTEM_REQUIRED):
raise OSError("SetThreadExecutionState failed")
def release(self) -> None:
if not self._set_execution_state(ES_CONTINUOUS):
raise OSError("SetThreadExecutionState failed")
class MacOSSleepBackend:
def __init__(self, popen=subprocess.Popen):
self._popen = popen
self._process = None
def acquire(self) -> None:
self._process = self._popen(
["caffeinate", "-i", "-w", str(os.getpid())],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def release(self) -> None:
if self._process is None:
return
process = self._process
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
self._process = None
class NoopSleepBackend:
def acquire(self) -> None:
pass
def release(self) -> None:
pass
def create_sleep_backend() -> SleepBackend:
system = platform.system()
if system == "Windows":
return WindowsSleepBackend()
if system == "Darwin":
return MacOSSleepBackend()
return NoopSleepBackend()
class SleepInhibitor:
"""Hold a platform sleep assertion while Buzz has pending work."""
def __init__(self, backend: SleepBackend | None = None, enabled: bool = True):
self._backend = backend if backend is not None else create_sleep_backend()
self._enabled = enabled
self._busy = False
self.active = False
def set_busy(self, busy: bool) -> None:
self._busy = busy
self._sync()
def set_enabled(self, enabled: bool) -> None:
self._enabled = enabled
self._sync()
def close(self) -> None:
self._busy = False
self._sync()
def _sync(self) -> None:
should_be_active = self._enabled and self._busy
if should_be_active == self.active:
return
try:
if should_be_active:
self._backend.acquire()
else:
self._backend.release()
except Exception:
logging.exception("Failed to update the system sleep assertion")
return
self.active = should_be_active

View file

@ -9,7 +9,8 @@ from PyQt6.QtCore import (
QThread,
QThreadPool,
QModelIndex,
pyqtSignal
pyqtSignal,
pyqtSlot,
)
from PyQt6.QtGui import QIcon
@ -27,6 +28,7 @@ from buzz.locale import _
from buzz.plugins.manager import PluginManager
from buzz.plugins.post_processing import FnRunnable
from buzz.settings.settings import APP_NAME, Settings
from buzz.sleep_inhibitor import SleepInhibitor
from buzz.update_checker import UpdateChecker, UpdateInfo
from buzz.widgets.update_dialog import UpdateDialog
from buzz.settings.shortcuts import Shortcuts
@ -69,6 +71,12 @@ class MainWindow(QMainWindow):
self.setAcceptDrops(True)
self.settings = Settings()
self.sleep_inhibitor = SleepInhibitor(
enabled=self.settings.value(
Settings.Key.PREVENT_SLEEP_WHILE_TRANSCRIBING,
True,
)
)
self.shortcuts = Shortcuts(settings=self.settings)
@ -155,6 +163,10 @@ class MainWindow(QMainWindow):
)
self.transcriber_worker.task_error.connect(self.on_task_error)
self.transcriber_worker.task_completed.connect(self.on_task_completed)
self.transcriber_worker.queue_busy_changed.connect(
self.on_queue_busy_changed,
Qt.ConnectionType.QueuedConnection,
)
self.transcriber_worker.completed.connect(self.transcriber_thread.quit)
@ -179,9 +191,19 @@ class MainWindow(QMainWindow):
def on_preferences_changed(self, preferences: Preferences):
self.preferences = preferences
self.save_preferences(preferences)
self.sleep_inhibitor.set_enabled(
self.settings.value(
Settings.Key.PREVENT_SLEEP_WHILE_TRANSCRIBING,
True,
)
)
self.folder_watcher.set_preferences(preferences.folder_watch)
self.folder_watcher.find_tasks()
@pyqtSlot(bool)
def on_queue_busy_changed(self, busy: bool):
self.sleep_inhibitor.set_busy(busy)
def save_preferences(self, preferences: Preferences):
self.settings.settings.beginGroup("preferences")
preferences.save(self.settings.settings)
@ -499,6 +521,7 @@ class MainWindow(QMainWindow):
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
self.save_geometry()
self.settings.settings.sync()
self.sleep_inhibitor.close()
if self.folder_watcher:
try:
@ -587,4 +610,4 @@ class MainWindow(QMainWindow):
update_info=self._update_info,
parent=self
)
dialog.exec()
dialog.exec()

View file

@ -222,6 +222,21 @@ class GeneralPreferencesWidget(QWidget):
self.force_cpu_checkbox.stateChanged.connect(self.on_force_cpu_changed)
layout.addRow(_("Disable GPU"), self.force_cpu_checkbox)
self.prevent_sleep_checkbox = QCheckBox(
_("Prevent system sleep while transcriptions are queued")
)
self.prevent_sleep_checkbox.setChecked(
self.settings.value(
key=Settings.Key.PREVENT_SLEEP_WHILE_TRANSCRIBING,
default_value=True,
)
)
self.prevent_sleep_checkbox.setObjectName("PreventSleepCheckbox")
self.prevent_sleep_checkbox.stateChanged.connect(
self.on_prevent_sleep_changed
)
layout.addRow("", self.prevent_sleep_checkbox)
self.setLayout(layout)
def on_default_export_file_name_changed(self, text: str):
@ -334,6 +349,12 @@ class GeneralPreferencesWidget(QWidget):
else:
os.environ.pop("BUZZ_REDUCE_GPU_MEMORY", None)
def on_prevent_sleep_changed(self, state: int):
self.settings.set_value(
Settings.Key.PREVENT_SLEEP_WHILE_TRANSCRIBING,
state == 2,
)
class ValidateOpenAIApiKeyJob(QRunnable):
class Signals(QObject):

View file

@ -0,0 +1,121 @@
from unittest.mock import Mock, patch
from uuid import uuid4
def test_task_activity_emits_only_busy_state_transitions():
from buzz.sleep_inhibitor import TaskActivity
busy_changes = []
activity = TaskActivity(busy_changes.append)
first_task = uuid4()
second_task = uuid4()
activity.add(first_task)
activity.add(second_task)
activity.finish(first_task)
activity.finish(second_task)
assert busy_changes == [True, False]
def test_task_activity_clear_marks_a_busy_queue_idle():
from buzz.sleep_inhibitor import TaskActivity
busy_changes = []
activity = TaskActivity(busy_changes.append)
activity.add(uuid4())
activity.clear()
assert busy_changes == [True, False]
def test_task_activity_counts_requeued_task_ids_separately():
from buzz.sleep_inhibitor import TaskActivity
busy_changes = []
activity = TaskActivity(busy_changes.append)
task_id = uuid4()
activity.add(task_id)
activity.add(task_id)
activity.finish(task_id)
assert busy_changes == [True]
activity.finish(task_id)
assert busy_changes == [True, False]
def test_sleep_inhibitor_holds_one_assertion_for_multiple_tasks():
from buzz.sleep_inhibitor import SleepInhibitor
backend = Mock()
inhibitor = SleepInhibitor(backend=backend, enabled=True)
inhibitor.set_busy(True)
inhibitor.set_busy(True)
assert inhibitor.active
inhibitor.set_busy(False)
assert not inhibitor.active
backend.acquire.assert_called_once_with()
backend.release.assert_called_once_with()
def test_sleep_inhibitor_reacts_to_setting_changes_while_busy():
from buzz.sleep_inhibitor import SleepInhibitor
backend = Mock()
inhibitor = SleepInhibitor(backend=backend, enabled=False)
inhibitor.set_busy(True)
assert not inhibitor.active
inhibitor.set_enabled(True)
assert inhibitor.active
inhibitor.set_enabled(False)
assert not inhibitor.active
backend.acquire.assert_called_once_with()
backend.release.assert_called_once_with()
def test_windows_backend_sets_and_clears_system_required_flag():
from buzz.sleep_inhibitor import (
ES_CONTINUOUS,
ES_SYSTEM_REQUIRED,
WindowsSleepBackend,
)
set_execution_state = Mock(return_value=1)
backend = WindowsSleepBackend(set_execution_state=set_execution_state)
backend.acquire()
backend.release()
assert set_execution_state.call_args_list == [
((ES_CONTINUOUS | ES_SYSTEM_REQUIRED,),),
((ES_CONTINUOUS,),),
]
def test_macos_backend_owns_caffeinate_process():
from buzz.sleep_inhibitor import MacOSSleepBackend
process = Mock()
popen = Mock(return_value=process)
backend = MacOSSleepBackend(popen=popen)
with patch("buzz.sleep_inhibitor.os.getpid", return_value=1234):
backend.acquire()
backend.release()
popen.assert_called_once_with(
["caffeinate", "-i", "-w", "1234"],
stdout=-3,
stderr=-3,
)
process.terminate.assert_called_once_with()
process.wait.assert_called_once_with(timeout=5)

View file

@ -45,6 +45,81 @@ class TestFileTranscriberQueueWorker:
simple_worker.cancel_task(task_id)
assert task_id in simple_worker.canceled_tasks
def test_cancel_force_terminated_current_task_releases_activity(
self, simple_worker
):
task = FileTranscriptionTask(
file_path=str(test_multibyte_utf8_audio_path),
transcription_options=TranscriptionOptions(),
file_transcription_options=FileTranscriptionOptions(),
model_path="mock_path",
)
busy_spy = unittest.mock.Mock()
simple_worker.queue_busy_changed.connect(busy_spy)
simple_worker.trigger_run.disconnect(simple_worker.run)
simple_worker.is_running = True
simple_worker.add_task(task)
assert simple_worker._get_next_task()
simple_worker.current.transcriber = unittest.mock.Mock()
simple_worker.current.transcriber_thread = unittest.mock.Mock()
simple_worker.current.transcriber_thread.wait.return_value = False
simple_worker.cancel_task(task.uid)
simple_worker.current.transcriber_thread.terminate.assert_called_once_with()
assert simple_worker.is_running is False
assert busy_spy.call_args_list == [
unittest.mock.call(True),
unittest.mock.call(False),
]
def test_cancel_does_not_terminate_task_that_replaces_current_during_wait(
self, simple_worker
):
first_task = FileTranscriptionTask(
file_path=str(test_multibyte_utf8_audio_path),
transcription_options=TranscriptionOptions(),
file_transcription_options=FileTranscriptionOptions(),
model_path="mock_path",
)
second_task = FileTranscriptionTask(
file_path=str(test_multibyte_utf8_audio_path),
transcription_options=TranscriptionOptions(),
file_transcription_options=FileTranscriptionOptions(),
model_path="mock_path",
)
busy_spy = unittest.mock.Mock()
simple_worker.queue_busy_changed.connect(busy_spy)
simple_worker.trigger_run.disconnect(simple_worker.run)
simple_worker.is_running = True
simple_worker.add_task(first_task)
simple_worker.add_task(second_task)
assert simple_worker._get_next_task()
first_transcriber = unittest.mock.Mock()
first_thread = unittest.mock.Mock()
second_thread = unittest.mock.Mock()
simple_worker.current.transcriber = first_transcriber
simple_worker.current.transcriber_thread = first_thread
def wait_for_first_thread(timeout=None):
if timeout == 5000:
assert simple_worker._get_next_task()
simple_worker.current.transcriber = unittest.mock.Mock()
simple_worker.current.transcriber_thread = second_thread
return False
return True
first_thread.wait.side_effect = wait_for_first_thread
simple_worker.cancel_task(first_task.uid)
first_thread.terminate.assert_called_once_with()
second_thread.terminate.assert_not_called()
assert simple_worker.current.task is second_task
assert simple_worker.is_running is True
assert busy_spy.call_args_list == [unittest.mock.call(True)]
def test_add_task_removes_from_canceled(self, simple_worker):
options = TranscriptionOptions(
model=TranscriptionModel(model_type=ModelType.WHISPER_CPP, whisper_model_size=WhisperModelSize.TINY),
@ -262,6 +337,26 @@ class TestFileTranscriberQueueWorkerRun:
# is_running stays True, nothing changed
assert simple_worker.is_running is True
def test_busy_signal_spans_all_pending_tasks(self, simple_worker):
first_task = self._make_task()
second_task = self._make_task()
busy_spy = unittest.mock.Mock()
simple_worker.queue_busy_changed.connect(busy_spy)
simple_worker.trigger_run.disconnect(simple_worker.run)
simple_worker.is_running = True
simple_worker.add_task(first_task)
simple_worker.add_task(second_task)
assert simple_worker._get_next_task()
simple_worker._on_task_finished()
assert simple_worker._get_next_task()
simple_worker._on_task_finished()
assert busy_spy.call_args_list == [
unittest.mock.call(True),
unittest.mock.call(False),
]
def test_run_stops_on_sentinel(self, simple_worker, qapp):
completed_spy = unittest.mock.Mock()
simple_worker.completed.connect(completed_spy)
@ -321,10 +416,15 @@ class TestFileTranscriberQueueWorkerRun:
def test_run_speech_extraction_failure_emits_error(self, simple_worker, qapp):
task = self._make_task(extract_speech=True)
simple_worker.tasks_queue.put(task)
simple_worker.trigger_run.disconnect(simple_worker.run)
simple_worker.is_running = True
simple_worker.add_task(task)
simple_worker.is_running = False
error_spy = unittest.mock.Mock()
busy_spy = unittest.mock.Mock()
simple_worker.task_error.connect(error_spy)
simple_worker.queue_busy_changed.connect(busy_spy)
with unittest.mock.patch.object(
FileTranscriberQueueWorker, '_extract_speech', return_value="error"
@ -335,6 +435,7 @@ class TestFileTranscriberQueueWorkerRun:
args = error_spy.call_args[0]
assert args[0] == task
assert simple_worker.is_running is False
assert busy_spy.call_args_list == [unittest.mock.call(False)]
def _run_extract_speech(self, simple_worker, messages, exitcode=0):
"""Drive _extract_speech with a scripted sequence of pipe messages."""
@ -459,4 +560,4 @@ def test_transcription_with_whisper_cpp_tiny_with_speech_extraction(worker):
args, kwargs = completed_spy.call_args
assert args[0] == task
assert len(args[1]) > 0
assert args[1][0]["text"] == "Test transcription with speech extraction."
assert args[1][0]["text"] == "Test transcription with speech extraction."

View file

@ -1,8 +1,9 @@
import logging
import os
import tempfile
import threading
from typing import List
from unittest.mock import patch, Mock
from unittest.mock import call, patch, Mock
import pytest
from PyQt6.QtCore import QSize, Qt
@ -44,6 +45,30 @@ class TestMainWindow:
assert window.windowIcon().pixmap(QSize(64, 64)).isNull() is False
window.close()
def test_queue_busy_changes_are_serialized_on_gui_thread(
self, qtbot, transcription_service
):
window = MainWindow(transcription_service)
qtbot.add_widget(window)
window.sleep_inhibitor = Mock()
background_emit = threading.Thread(
target=lambda: window.transcriber_worker.queue_busy_changed.emit(False)
)
background_emit.start()
background_emit.join()
window.transcriber_worker.queue_busy_changed.emit(True)
qtbot.wait_until(
lambda: window.sleep_inhibitor.set_busy.call_count == 2,
timeout=1000,
)
assert window.sleep_inhibitor.set_busy.call_args_list == [
call(False),
call(True),
]
window.close()
def test_should_run_file_transcription_task(
self, qtbot: QtBot, transcription_service
):

View file

@ -26,6 +26,35 @@ def reset_custom_openai_base_url():
class TestGeneralPreferencesWidget:
def test_prevent_sleep_while_transcribing_preference(self, qtbot):
settings = Settings()
previous = settings.value(
key=Settings.Key.PREVENT_SLEEP_WHILE_TRANSCRIBING,
default_value=True,
)
settings.set_value(Settings.Key.PREVENT_SLEEP_WHILE_TRANSCRIBING, False)
try:
widget = GeneralPreferencesWidget()
qtbot.add_widget(widget)
checkbox = widget.findChild(QCheckBox, "PreventSleepCheckbox")
assert checkbox is not None
assert not checkbox.isChecked()
checkbox.click()
assert checkbox.isChecked()
assert widget.settings.value(
key=Settings.Key.PREVENT_SLEEP_WHILE_TRANSCRIBING,
default_value=False,
)
finally:
settings.set_value(
Settings.Key.PREVENT_SLEEP_WHILE_TRANSCRIBING,
previous,
)
def test_should_disable_test_button_if_no_api_key(self, qtbot, mocker):
mocker.patch(
"buzz.widgets.preferences_dialog.general_preferences_widget.get_password",
@ -184,4 +213,4 @@ class TestTestOpenAIApiKeyJob:
mock_success.assert_not_called()
mock_failed.assert_called_once_with("Incorrect API key provided")
mock_client.models.list.assert_called_once()
mock_client.models.list.assert_called_once()