diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f8c6faa0..929a41752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Install dependencies - run: poetry config experimental.new-installer false && poetry install + run: poetry install - name: Test run: | @@ -122,7 +122,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Install dependencies - run: poetry config experimental.new-installer false && poetry install + run: poetry install - name: Bundle run: | @@ -237,7 +237,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Install dependencies - run: poetry config experimental.new-installer false && poetry install + run: poetry install - name: Test run: | diff --git a/buzz/gui.py b/buzz/gui.py index 5b53c47cb..97f1a1893 100644 --- a/buzz/gui.py +++ b/buzz/gui.py @@ -1,7 +1,6 @@ import enum import json import logging -import os import sys from enum import auto from typing import Dict, List, Optional, Tuple @@ -16,8 +15,7 @@ from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QLabel, QMainWindow, QMessageBox, QPlainTextEdit, - QPushButton, QVBoxLayout, QHBoxLayout, QWidget, QGroupBox, QTableWidget, - QMenuBar, QFormLayout, QTableWidgetItem, + QPushButton, QVBoxLayout, QHBoxLayout, QWidget, QGroupBox, QMenuBar, QFormLayout, QAbstractItemView, QListWidget, QListWidgetItem, QSizePolicy) from buzz.cache import TasksCache @@ -45,6 +43,7 @@ from .widgets.openai_api_key_line_edit import OpenAIAPIKeyLineEdit from .widgets.preferences_dialog import PreferencesDialog from .widgets.toolbar import ToolBar +from .widgets.transcription_tasks_table_widget import TranscriptionTasksTableWidget from .widgets.transcription_viewer_widget import TranscriptionViewerWidget @@ -722,91 +721,6 @@ def is_version_lower(version_a: str, version_b: str): return version_a.replace('.', '') < version_b.replace('.', '') -class TranscriptionTasksTableWidget(QTableWidget): - class Column(enum.Enum): - TASK_ID = 0 - FILE_NAME = auto() - STATUS = auto() - - return_clicked = pyqtSignal() - - def __init__(self, parent: Optional[QWidget] = None): - super().__init__(parent) - - self.setRowCount(0) - self.setAlternatingRowColors(True) - - self.setColumnCount(3) - self.setColumnHidden(0, True) - - self.verticalHeader().hide() - self.setHorizontalHeaderLabels([_('ID'), _('File Name'), _('Status')]) - self.setColumnWidth(self.Column.FILE_NAME.value, 250) - self.setColumnWidth(self.Column.STATUS.value, 180) - self.horizontalHeader().setMinimumSectionSize(180) - - self.setSelectionBehavior( - QAbstractItemView.SelectionBehavior.SelectRows) - - def upsert_task(self, task: FileTranscriptionTask): - task_row_index = self.task_row_index(task.id) - if task_row_index is None: - self.insertRow(self.rowCount()) - - row_index = self.rowCount() - 1 - task_id_widget_item = QTableWidgetItem(str(task.id)) - self.setItem(row_index, self.Column.TASK_ID.value, - task_id_widget_item) - - file_name_widget_item = QTableWidgetItem( - os.path.basename(task.file_path)) - file_name_widget_item.setFlags( - file_name_widget_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.setItem(row_index, self.Column.FILE_NAME.value, - file_name_widget_item) - - status_widget_item = QTableWidgetItem( - task.status.value.title() if task.status is not None else '') - status_widget_item.setFlags( - status_widget_item.flags() & ~Qt.ItemFlag.ItemIsEditable) - self.setItem(row_index, self.Column.STATUS.value, - status_widget_item) - else: - status_widget = self.item(task_row_index, self.Column.STATUS.value) - - if task.status == FileTranscriptionTask.Status.IN_PROGRESS: - status_widget.setText( - f'{_("In Progress")} ({task.fraction_completed :.0%})') - elif task.status == FileTranscriptionTask.Status.COMPLETED: - status_widget.setText(_('Completed')) - elif task.status == FileTranscriptionTask.Status.FAILED: - status_widget.setText(f'{_("Failed")} ({task.error})') - elif task.status == FileTranscriptionTask.Status.CANCELED: - status_widget.setText(_('Canceled')) - - def clear_task(self, task_id: int): - task_row_index = self.task_row_index(task_id) - if task_row_index is not None: - self.removeRow(task_row_index) - - def task_row_index(self, task_id: int) -> int | None: - table_items_matching_task_id = [item for item in self.findItems(str(task_id), Qt.MatchFlag.MatchExactly) if - item.column() == self.Column.TASK_ID.value] - if len(table_items_matching_task_id) == 0: - return None - return table_items_matching_task_id[0].row() - - @staticmethod - def find_task_id(index: QModelIndex): - sibling_index = index.siblingAtColumn(TranscriptionTasksTableWidget.Column.TASK_ID.value).data() - return int(sibling_index) if sibling_index is not None else None - - def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - if event.key() == Qt.Key.Key_Return: - self.return_clicked.emit() - super().keyPressEvent(event) - - class MainWindowToolbar(ToolBar): new_transcription_action_triggered: pyqtSignal open_transcript_action_triggered: pyqtSignal diff --git a/buzz/transcriber.py b/buzz/transcriber.py index 74e87234c..5447d0fee 100644 --- a/buzz/transcriber.py +++ b/buzz/transcriber.py @@ -98,6 +98,9 @@ class Status(enum.Enum): status: Optional[Status] = None fraction_completed = 0.0 error: Optional[str] = None + queued_at: Optional[datetime.datetime] = None + started_at: Optional[datetime.datetime] = None + completed_at: Optional[datetime.datetime] = None class RecordingTranscriber(QObject): @@ -769,9 +772,13 @@ def run(self): self.current_transcriber.error.connect(self.run) self.current_transcriber.completed.connect(self.run) + self.current_task.started_at = datetime.datetime.now() self.current_transcriber_thread.start() def add_task(self, task: FileTranscriptionTask): + if task.queued_at is None: + task.queued_at = datetime.datetime.now() + self.tasks_queue.put(task) task.status = FileTranscriptionTask.Status.QUEUED self.task_updated.emit(task) @@ -802,6 +809,7 @@ def on_task_completed(self, segments: List[Segment]): if self.current_task is not None: self.current_task.status = FileTranscriptionTask.Status.COMPLETED self.current_task.segments = segments + self.current_task.completed_at = datetime.datetime.now() self.task_updated.emit(self.current_task) def stop(self): diff --git a/buzz/widgets/transcription_tasks_table_widget.py b/buzz/widgets/transcription_tasks_table_widget.py new file mode 100644 index 000000000..7edaf40a8 --- /dev/null +++ b/buzz/widgets/transcription_tasks_table_widget.py @@ -0,0 +1,116 @@ +import datetime +import enum +import os +from enum import auto +from typing import Optional + +from PyQt6 import QtGui +from PyQt6.QtCore import pyqtSignal, Qt, QModelIndex +from PyQt6.QtWidgets import QTableWidget, QWidget, QAbstractItemView, QTableWidgetItem + +from buzz.locale import _ +from buzz.transcriber import FileTranscriptionTask + + +class TranscriptionTasksTableWidget(QTableWidget): + class Column(enum.Enum): + TASK_ID = 0 + FILE_NAME = auto() + STATUS = auto() + + return_clicked = pyqtSignal() + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + + self.setRowCount(0) + self.setAlternatingRowColors(True) + + self.setColumnCount(3) + self.setColumnHidden(0, True) + + self.verticalHeader().hide() + self.setHorizontalHeaderLabels([_('ID'), _('File Name'), _('Status')]) + self.setColumnWidth(self.Column.FILE_NAME.value, 250) + self.setColumnWidth(self.Column.STATUS.value, 180) + self.horizontalHeader().setMinimumSectionSize(180) + + self.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows) + + def upsert_task(self, task: FileTranscriptionTask): + task_row_index = self.task_row_index(task.id) + if task_row_index is None: + self.insertRow(self.rowCount()) + + row_index = self.rowCount() - 1 + task_id_widget_item = QTableWidgetItem(str(task.id)) + self.setItem(row_index, self.Column.TASK_ID.value, + task_id_widget_item) + + file_name_widget_item = QTableWidgetItem( + os.path.basename(task.file_path)) + file_name_widget_item.setFlags( + file_name_widget_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.setItem(row_index, self.Column.FILE_NAME.value, + file_name_widget_item) + + status_widget_item = QTableWidgetItem(self.get_status_text(task)) + status_widget_item.setFlags( + status_widget_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.setItem(row_index, self.Column.STATUS.value, + status_widget_item) + else: + status_widget = self.item(task_row_index, self.Column.STATUS.value) + status_widget.setText(self.get_status_text(task)) + + @staticmethod + def format_timedelta(delta: datetime.timedelta): + mm, ss = divmod(delta.seconds, 60) + result = f'{ss}s' + if mm == 0: + return result + hh, mm = divmod(mm, 60) + result = f'{mm}m {result}' + if hh == 0: + return result + return f'{hh}h {result}' + + @staticmethod + def get_status_text(task: FileTranscriptionTask): + if task.status == FileTranscriptionTask.Status.IN_PROGRESS: + return ( + f'{_("In Progress")} ({task.fraction_completed :.0%})') + elif task.status == FileTranscriptionTask.Status.COMPLETED: + status = _('Completed') + if task.started_at is not None and task.completed_at is not None: + status += f" ({TranscriptionTasksTableWidget.format_timedelta(task.completed_at - task.started_at)})" + return status + elif task.status == FileTranscriptionTask.Status.FAILED: + return f'{_("Failed")} ({task.error})' + elif task.status == FileTranscriptionTask.Status.CANCELED: + return _('Canceled') + elif task.status == FileTranscriptionTask.Status.QUEUED: + return _('Queued') + + def clear_task(self, task_id: int): + task_row_index = self.task_row_index(task_id) + if task_row_index is not None: + self.removeRow(task_row_index) + + def task_row_index(self, task_id: int) -> int | None: + table_items_matching_task_id = [item for item in self.findItems(str(task_id), Qt.MatchFlag.MatchExactly) if + item.column() == self.Column.TASK_ID.value] + if len(table_items_matching_task_id) == 0: + return None + return table_items_matching_task_id[0].row() + + @staticmethod + def find_task_id(index: QModelIndex): + sibling_index = index.siblingAtColumn(TranscriptionTasksTableWidget.Column.TASK_ID.value).data() + return int(sibling_index) if sibling_index is not None else None + + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + if event.key() == Qt.Key.Key_Return: + self.return_clicked.emit() + super().keyPressEvent(event) diff --git a/tests/gui_test.py b/tests/gui_test.py index 0af738389..63ae8e17c 100644 --- a/tests/gui_test.py +++ b/tests/gui_test.py @@ -17,7 +17,7 @@ from buzz.gui import (AboutDialog, AdvancedSettingsDialog, AudioDevicesComboBox, FileTranscriberWidget, LanguagesComboBox, MainWindow, RecordingTranscriberWidget, - TemperatureValidator, TranscriptionTasksTableWidget, HuggingFaceSearchLineEdit, + TemperatureValidator, HuggingFaceSearchLineEdit, TranscriptionOptionsGroupBox) from buzz.model_loader import ModelType from buzz.settings.settings import Settings @@ -106,7 +106,7 @@ def get_test_asset(filename: str): status=FileTranscriptionTask.Status.CANCELED), FileTranscriptionTask(file_path='', transcription_options=TranscriptionOptions(), file_transcription_options=FileTranscriptionOptions(file_paths=[]), model_path='', - status=FileTranscriptionTask.Status.FAILED), + status=FileTranscriptionTask.Status.FAILED, error='Error'), ] @@ -179,7 +179,7 @@ def test_should_load_tasks_from_cache(self, qtbot, tasks_cache): table_widget.selectRow(1) assert window.toolbar.open_transcript_action.isEnabled() is False - assert table_widget.item(2, 2).text() == 'Failed' + assert table_widget.item(2, 2).text() == 'Failed (Error)' table_widget.selectRow(2) assert window.toolbar.open_transcript_action.isEnabled() is False window.close() @@ -261,7 +261,7 @@ def _assert_task_status(table_widget: QTableWidget, row_index: int, expected_sta def assert_task_canceled(): assert table_widget.rowCount() > 0 assert table_widget.item(row_index, 1).text() == 'whisper-french.mp3' - assert table_widget.item(row_index, 2).text() == expected_status + assert expected_status in table_widget.item(row_index, 2).text() return assert_task_canceled @@ -355,33 +355,6 @@ def test_should_validate_temperature(self, text: str, state: QValidator.State): assert self.validator.validate(text, 0)[0] == state -class TestTranscriptionTasksTableWidget: - - def test_upsert_task(self, qtbot: QtBot): - widget = TranscriptionTasksTableWidget() - qtbot.add_widget(widget) - - task = FileTranscriptionTask(id=0, file_path='testdata/whisper-french.mp3', - transcription_options=TranscriptionOptions(), - file_transcription_options=FileTranscriptionOptions( - file_paths=['testdata/whisper-french.mp3']), model_path='', - status=FileTranscriptionTask.Status.QUEUED) - - widget.upsert_task(task) - - assert widget.rowCount() == 1 - assert widget.item(0, 1).text() == 'whisper-french.mp3' - assert widget.item(0, 2).text() == 'Queued' - - task.status = FileTranscriptionTask.Status.IN_PROGRESS - task.fraction_completed = 0.3524 - widget.upsert_task(task) - - assert widget.rowCount() == 1 - assert widget.item(0, 1).text() == 'whisper-french.mp3' - assert widget.item(0, 2).text() == 'In Progress (35%)' - - class TestRecordingTranscriberWidget: def test_should_set_window_title(self, qtbot: QtBot): widget = RecordingTranscriberWidget() diff --git a/tests/widgets/transcription_tasks_table_widget_test.py b/tests/widgets/transcription_tasks_table_widget_test.py new file mode 100644 index 000000000..11838654a --- /dev/null +++ b/tests/widgets/transcription_tasks_table_widget_test.py @@ -0,0 +1,58 @@ +import datetime + +from pytestqt.qtbot import QtBot + +from buzz.transcriber import FileTranscriptionTask, TranscriptionOptions, FileTranscriptionOptions +from buzz.widgets.transcription_tasks_table_widget import TranscriptionTasksTableWidget + + +class TestTranscriptionTasksTableWidget: + + def test_upsert_task(self, qtbot: QtBot): + widget = TranscriptionTasksTableWidget() + qtbot.add_widget(widget) + + task = FileTranscriptionTask(id=0, file_path='testdata/whisper-french.mp3', + transcription_options=TranscriptionOptions(), + file_transcription_options=FileTranscriptionOptions( + file_paths=['testdata/whisper-french.mp3']), model_path='', + status=FileTranscriptionTask.Status.QUEUED) + task.queued_at = datetime.datetime(2023, 4, 12, 0, 0, 0) + task.started_at = datetime.datetime(2023, 4, 12, 0, 0, 5) + + widget.upsert_task(task) + + assert widget.rowCount() == 1 + assert widget.item(0, 1).text() == 'whisper-french.mp3' + assert widget.item(0, 2).text() == 'Queued' + + task.status = FileTranscriptionTask.Status.IN_PROGRESS + task.fraction_completed = 0.3524 + widget.upsert_task(task) + + assert widget.rowCount() == 1 + assert widget.item(0, 1).text() == 'whisper-french.mp3' + assert widget.item(0, 2).text() == 'In Progress (35%)' + + task.status = FileTranscriptionTask.Status.COMPLETED + task.completed_at = datetime.datetime(2023, 4, 12, 0, 0, 10) + widget.upsert_task(task) + + assert widget.rowCount() == 1 + assert widget.item(0, 1).text() == 'whisper-french.mp3' + assert widget.item(0, 2).text() == 'Completed (5s)' + + def test_upsert_task_no_timings(self, qtbot: QtBot): + widget = TranscriptionTasksTableWidget() + qtbot.add_widget(widget) + + task = FileTranscriptionTask(id=0, file_path='testdata/whisper-french.mp3', + transcription_options=TranscriptionOptions(), + file_transcription_options=FileTranscriptionOptions( + file_paths=['testdata/whisper-french.mp3']), model_path='', + status=FileTranscriptionTask.Status.COMPLETED) + widget.upsert_task(task) + + assert widget.rowCount() == 1 + assert widget.item(0, 1).text() == 'whisper-french.mp3' + assert widget.item(0, 2).text() == 'Completed'