diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97c3d198..8a83b46d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,6 +155,10 @@ jobs: if [ "$RUNNER_OS" == "macOS" ]; then brew install create-dmg + + # kill XProtect to prevent https://github.com/actions/runner-images/issues/7522 + sudo pkill -9 XProtect >/dev/null || true; + while pgrep XProtect; do sleep 3; done; # create variables CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 diff --git a/buzz/assets/resize_black.svg b/buzz/assets/resize_black.svg new file mode 100644 index 00000000..ac973d87 --- /dev/null +++ b/buzz/assets/resize_black.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/buzz/db/dao/transcription_segment_dao.py b/buzz/db/dao/transcription_segment_dao.py index 1b28c6c7..bb222555 100644 --- a/buzz/db/dao/transcription_segment_dao.py +++ b/buzz/db/dao/transcription_segment_dao.py @@ -25,6 +25,18 @@ class TranscriptionSegmentDAO(DAO[TranscriptionSegment]): query.bindValue(":transcription_id", str(transcription_id)) return self._execute_all(query) + def delete_segments(self, transcription_id: UUID): + query = self._create_query() + query.prepare( + f""" + DELETE FROM {self.table} + WHERE transcription_id = :transcription_id + """ + ) + query.bindValue(":transcription_id", str(transcription_id)) + if not query.exec(): + raise Exception(query.lastError().text()) + def update_segment_translation(self, segment_id: int, translation: str): query = self._create_query() query.prepare( diff --git a/buzz/db/db.py b/buzz/db/db.py index a4936316..287a5b10 100644 --- a/buzz/db/db.py +++ b/buzz/db/db.py @@ -35,5 +35,6 @@ def _setup_db(path: str) -> QSqlDatabase: db.setDatabaseName(path) if not db.open(): raise RuntimeError(f"Failed to open database connection: {db.databaseName()}") + db.exec('PRAGMA foreign_keys = ON') logging.debug("Database connection opened: %s", db.databaseName()) return db diff --git a/buzz/db/service/transcription_service.py b/buzz/db/service/transcription_service.py index d6f75274..560298a8 100644 --- a/buzz/db/service/transcription_service.py +++ b/buzz/db/service/transcription_service.py @@ -44,6 +44,19 @@ class TranscriptionService: ) ) + def replace_transcription_segments(self, id: UUID, segments: List[Segment]): + self.transcription_segment_dao.delete_segments(id) + for segment in segments: + self.transcription_segment_dao.insert( + TranscriptionSegment( + start_time=segment.start, + end_time=segment.end, + text=segment.text, + translation='', + transcription_id=str(id), + ) + ) + def get_transcription_segments(self, transcription_id: UUID): return self.transcription_segment_dao.get_segments(transcription_id) diff --git a/buzz/locale/lv_LV/LC_MESSAGES/buzz.po b/buzz/locale/lv_LV/LC_MESSAGES/buzz.po index 3c61b2b1..f87dbf1b 100644 --- a/buzz/locale/lv_LV/LC_MESSAGES/buzz.po +++ b/buzz/locale/lv_LV/LC_MESSAGES/buzz.po @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-11 19:46+0300\n" -"PO-Revision-Date: 2024-07-11 19:48+0300\n" +"POT-Creation-Date: 2024-08-17 11:00+0300\n" +"PO-Revision-Date: 2024-08-17 11:02+0300\n" "Last-Translator: \n" "Language-Team: \n" "Language: lv_LV\n" @@ -29,12 +29,14 @@ msgstr "https://example.com/audio.mp3" #: buzz/widgets/import_url_dialog.py:28 #: buzz/widgets/preferences_dialog/preferences_dialog.py:69 #: buzz/widgets/transcriber/advanced_settings_dialog.py:97 +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:290 #: buzz/widgets/main_window.py:234 msgid "Ok" msgstr "Labi" #: buzz/widgets/import_url_dialog.py:29 #: buzz/widgets/preferences_dialog/preferences_dialog.py:70 +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:291 #: buzz/widgets/main_window.py:235 msgid "Cancel" msgstr "Atcelt" @@ -114,7 +116,7 @@ msgid "" msgstr "" "OpenAI API atbilde ir nederīga. Lūdzu pārbaudiet API Adresi un savu atslēgu. " "Atpazīšana un tulkošana joprojām var strādāt, ja API neatbalsta atslēgu " -"pārbaudi. " +"pārbaudi." #: buzz/widgets/preferences_dialog/folder_watch_preferences_widget.py:42 msgid "Enable folder watch" @@ -201,7 +203,7 @@ msgid "Download failed" msgstr "Lejupielāde neizdevās" #: buzz/widgets/preferences_dialog/models_preferences_widget.py:259 -#: buzz/widgets/main_window.py:291 buzz/model_loader.py:462 +#: buzz/widgets/main_window.py:291 buzz/model_loader.py:478 msgid "Error" msgstr "Kļūda" @@ -378,11 +380,11 @@ msgstr "Gaida MI tulkojumu..." msgid "Microphone:" msgstr "Mikrofons:" -#: buzz/widgets/recording_transcriber_widget.py:413 +#: buzz/widgets/recording_transcriber_widget.py:414 msgid "An error occurred while starting a new recording:" msgstr "Sākot jaunu ierakstu notikusi kļūda:" -#: buzz/widgets/recording_transcriber_widget.py:417 +#: buzz/widgets/recording_transcriber_widget.py:418 msgid "" "Please check your audio devices or check the application logs for more " "information." @@ -427,23 +429,35 @@ msgstr "Skats" msgid "Timestamps" msgstr "Laiks" -#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:140 +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:160 msgid "Export" msgstr "Eksportēt" -#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:154 +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:174 #: buzz/transcriber/transcriber.py:24 msgid "Translate" msgstr "Tulkot" -#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:244 +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:184 +msgid "Resize" +msgstr "Mainīt garumu" + +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:274 msgid "API Key Required" msgstr "API atslēgas kļūda" -#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:245 +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:275 msgid "Please enter OpenAI API Key in preferences" msgstr "Lūdzu ievadiet OpenAI API atslēgu iestatījumos" +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:292 +msgid "Desired subtitle length" +msgstr "Vēlamais teksta garums" + +#: buzz/widgets/transcription_viewer/transcription_viewer_widget.py:293 +msgid "Enter target characters per subtitle:" +msgstr "Ievadiet vēlamo simbolu skaitu tekstā:" + #: buzz/widgets/transcription_viewer/export_transcription_menu.py:76 msgid "Save File" msgstr "Saglabāt failu" @@ -525,7 +539,7 @@ msgstr "Neizdevās saglabāt OpenAI API atslēgu atslēgu saišķī" msgid "Transcribe" msgstr "Atpazīt" -#: buzz/model_loader.py:490 +#: buzz/model_loader.py:507 msgid "A connection error occurred" msgstr "Notika savienojuma kļūda" diff --git a/buzz/widgets/icon.py b/buzz/widgets/icon.py index 86c5667d..36616da5 100644 --- a/buzz/widgets/icon.py +++ b/buzz/widgets/icon.py @@ -78,6 +78,9 @@ class TranslateIcon(Icon): def __init__(self, parent: QWidget): super().__init__(get_path("assets/translate_black.svg"), parent) +class ResizeIcon(Icon): + def __init__(self, parent: QWidget): + super().__init__(get_path("assets/resize_black.svg"), parent) class VisibilityIcon(Icon): def __init__(self, parent: QWidget): diff --git a/buzz/widgets/transcription_viewer/transcription_segments_editor_widget.py b/buzz/widgets/transcription_viewer/transcription_segments_editor_widget.py index 11ec25ff..7ccdc42e 100644 --- a/buzz/widgets/transcription_viewer/transcription_segments_editor_widget.py +++ b/buzz/widgets/transcription_viewer/transcription_segments_editor_widget.py @@ -123,16 +123,19 @@ class TranscriptionSegmentsEditorWidget(QTableView): # Show start before end self.horizontalHeader().swapSections(1, 2) - font_metrics = QFontMetrics(self.font()) - max_row_height = font_metrics.height() * 4 - for row in range(self.model().rowCount()): - self.setRowHeight(row, max_row_height) + self.init_row_height() self.setColumnWidth(Column.START.value, 95) self.setColumnWidth(Column.END.value, 95) self.setWordWrap(True) + def init_row_height(self): + font_metrics = QFontMetrics(self.font()) + max_row_height = font_metrics.height() * 4 + for row in range(self.model().rowCount()): + self.setRowHeight(row, max_row_height) + def has_non_empty_translation(self) -> bool: for i in range(self.model().rowCount()): if self.model().record(i).value("translation").strip(): diff --git a/buzz/widgets/transcription_viewer/transcription_viewer_widget.py b/buzz/widgets/transcription_viewer/transcription_viewer_widget.py index 42e4bd8a..8edf8a04 100644 --- a/buzz/widgets/transcription_viewer/transcription_viewer_widget.py +++ b/buzz/widgets/transcription_viewer/transcription_viewer_widget.py @@ -1,10 +1,9 @@ import logging -import platform from typing import Optional from uuid import UUID -from PyQt6.QtCore import Qt, QThread -from PyQt6.QtGui import QFont +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QFont, QShowEvent from PyQt6.QtMultimedia import QMediaPlayer from PyQt6.QtSql import QSqlRecord from PyQt6.QtWidgets import ( @@ -13,8 +12,13 @@ from PyQt6.QtWidgets import ( QToolButton, QLabel, QMessageBox, + QInputDialog, + QDialogButtonBox, ) +import srt +from srt_equalizer import srt_equalizer + from buzz.locale import _ from buzz.db.entity.transcription import Transcription from buzz.db.service.transcription_service import TranscriptionService @@ -25,12 +29,13 @@ from buzz.store.keyring_store import get_password, Key from buzz.widgets.audio_player import AudioPlayer from buzz.widgets.icon import ( FileDownloadIcon, - TranslateIcon + TranslateIcon, + ResizeIcon, ) from buzz.translator import Translator from buzz.widgets.text_display_box import TextDisplayBox from buzz.widgets.toolbar import ToolBar -from buzz.transcriber.transcriber import TranscriptionOptions +from buzz.transcriber.transcriber import TranscriptionOptions, Segment from buzz.widgets.transcriber.advanced_settings_dialog import AdvancedSettingsDialog from buzz.widgets.transcription_viewer.export_transcription_menu import ( ExportTranscriptionMenu, @@ -47,7 +52,18 @@ from buzz.widgets.transcription_viewer.transcription_view_mode_tool_button impor ) +class OkEnabledInputDialog(QInputDialog): + def showEvent(self, event: QShowEvent) -> None: + super().showEvent(event) + button_box = self.findChild(QDialogButtonBox) + if button_box: + ok_button = button_box.button(QDialogButtonBox.StandardButton.Ok) + if ok_button: + ok_button.setEnabled(True) + + class TranscriptionViewerWidget(QWidget): + resize_button_clicked = pyqtSignal() transcription: Transcription settings = Settings() @@ -160,6 +176,17 @@ class TranscriptionViewerWidget(QWidget): toolbar.addWidget(translate_button) + resize_button = QToolButton() + resize_button.setText(_("Resize")) + resize_button.setObjectName("resize_button") + resize_button.setIcon(ResizeIcon(self)) + resize_button.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonTextBesideIcon + ) + resize_button.clicked.connect(self.on_resize_button_clicked) + + toolbar.addWidget(resize_button) + layout.setMenuBar(toolbar) layout.addWidget(self.table_widget) @@ -255,6 +282,63 @@ class TranscriptionViewerWidget(QWidget): for segment in segments: self.translator.enqueue(segment.value("text"), segment.value("id")) + def on_resize_button_clicked(self): + target_chars_dialog = OkEnabledInputDialog(self) + target_chars_dialog.setOkButtonText(_("Ok")) + target_chars_dialog.setCancelButtonText(_("Cancel")) + target_chars_dialog.setWindowTitle(_("Desired subtitle length")) + target_chars_dialog.setLabelText(_("Enter target characters per subtitle:")) + target_chars_dialog.setIntValue(42) + target_chars_dialog.setIntMaximum(100) + target_chars_dialog.setIntMinimum(1) + target_chars_dialog.setIntStep(1) + target_chars_dialog.setInputMode(QInputDialog.InputMode.IntInput) + + if target_chars_dialog.exec() == QInputDialog.DialogCode.Accepted: + target_chars = target_chars_dialog.intValue() + else: + return + + segments = self.table_widget.segments() + subs = [] + for segment in segments: + subtitle = srt.Subtitle( + index=segment.value("id"), + start=segment.value("start_time"), + end=segment.value("end_time"), + content=segment.value("text") + ) + subs.append(subtitle) + + resized_subs = [] + last_index = 0 + + # Limit each subtitle to a maximum character length, splitting into + # multiple subtitle items if necessary. + for sub in subs: + new_subs = srt_equalizer.split_subtitle( + sub=sub, target_chars=target_chars, start_from_index=last_index, method="punctuation") + last_index = new_subs[-1].index + resized_subs.extend(new_subs) + + segments = [ + Segment( + round(sub.start), + round(sub.end), + sub.content + ) + for sub in resized_subs + if round(sub.start) != round(sub.end) + ] + + self.transcription_service.replace_transcription_segments( + UUID(hex=self.transcription.id), + segments + ) + + self.table_widget.model().select() + self.table_widget.init_row_height() + def closeEvent(self, event): self.hide() diff --git a/poetry.lock b/poetry.lock index 4b05c8b5..e2481b4a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2682,6 +2682,30 @@ CFFI = ">=1.0" [package.extras] numpy = ["NumPy"] +[[package]] +name = "srt" +version = "3.5.3" +description = "A tiny library for parsing, modifying, and composing SRT files." +optional = false +python-versions = ">=2.7" +files = [ + {file = "srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0"}, +] + +[[package]] +name = "srt-equalizer" +version = "0.1.10" +description = "Transform subtitle line lengths, splitting into multiple subtitle fragments if necessary. " +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "srt_equalizer-0.1.10-py3-none-any.whl", hash = "sha256:7b0cca73374ff7351badf2dc5266cabefdfd36b91ff736a535445562036cf8ed"}, + {file = "srt_equalizer-0.1.10.tar.gz", hash = "sha256:5f6b1b2c48b12bb1caab13822d7ddd0a54a87772b824a0aa2ba64c033d379353"}, +] + +[package.dependencies] +srt = ">=3.5.3,<4.0.0" + [[package]] name = "stable-ts" version = "2.15.9" @@ -3400,4 +3424,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "c62d3e0a79ed56cccbbd28548655a072ca9e144fc5700260f788f1b88057faa7" +content-hash = "3997325c46fdc5fe89d1467778f846a19eced146d7879c3ae3658ccdc8861ada" diff --git a/pyproject.toml b/pyproject.toml index 1a792265..71b45218 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ faster-whisper = "1.0.1" openai-whisper = "v20231117" transformers = "4.39.1" polib = "^1.2.0" +srt-equalizer = "^0.1.10" [tool.poetry.group.dev.dependencies] autopep8 = "^1.7.0" diff --git a/tests/widgets/transcription_viewer_test.py b/tests/widgets/transcription_viewer_test.py index 99980b30..2e71eb69 100644 --- a/tests/widgets/transcription_viewer_test.py +++ b/tests/widgets/transcription_viewer_test.py @@ -1,8 +1,8 @@ import uuid -import time import pytest from pytestqt.qtbot import QtBot +from unittest.mock import patch from buzz.locale import _ from buzz.db.entity.transcription import Transcription @@ -13,6 +13,11 @@ from buzz.widgets.transcription_viewer.transcription_view_mode_tool_button impor TranscriptionViewModeToolButton, ViewMode ) +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QToolButton, + QInputDialog, +) from buzz.widgets.transcription_viewer.transcription_segments_editor_widget import ( TranscriptionSegmentsEditorWidget, ) @@ -77,6 +82,40 @@ class TestTranscriptionViewerWidget: editor.model().setData(editor.model().index(0, 3), "Biens") widget.close() + @patch('buzz.widgets.transcription_viewer.transcription_viewer_widget.OkEnabledInputDialog') + def test_should_resize_segment_text(self, mock_dialog, qtbot, transcription, transcription_service, shortcuts): + mock_dialog.return_value.exec.return_value = QInputDialog.DialogCode.Accepted + mock_dialog.return_value.intValue.return_value = 5 + + widget = TranscriptionViewerWidget( + transcription, transcription_service, shortcuts + ) + qtbot.add_widget(widget) + + editor = widget.findChild(TranscriptionSegmentsEditorWidget) + + assert editor.model().index(1, 1).data() == 329 + assert editor.model().index(1, 2).data() == 299 + assert editor.model().index(1, 3).data() == "venue dans" + + with qtbot.waitSignal(widget.resize_button_clicked, timeout=1000): + qtbot.mouseClick(widget.findChild(QToolButton, "resize_button"), Qt.MouseButton.LeftButton) + widget.resize_button_clicked.emit() + + assert editor.model().index(0, 1).data() == 299 + assert editor.model().index(0, 2).data() == 40 + assert editor.model().index(0, 3).data() == "Bien" + + assert editor.model().index(1, 1).data() == 314 + assert editor.model().index(1, 2).data() == 299 + assert editor.model().index(1, 3).data() == "venue" + + assert editor.model().index(2, 1).data() == 329 + assert editor.model().index(2, 2).data() == 314 + assert editor.model().index(2, 3).data() == "dans" + + widget.close() + def test_text_button_changes_view_mode( self, qtbot, transcription, transcription_service, shortcuts ):