mirror of
https://github.com/chidiwilliams/buzz.git
synced 2026-10-21 05:31:23 +00:00
Prevent system sleep while transcriptions are queued (#1593)
Co-authored-by: Raivis Dejus <raivisd@scandiweb.com>
This commit is contained in:
parent
646c61bed8
commit
aaf98634cd
24 changed files with 611 additions and 18 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
151
buzz/sleep_inhibitor.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
121
tests/sleep_inhibitor_test.py
Normal file
121
tests/sleep_inhibitor_test.py
Normal 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)
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue