mirror of
https://github.com/chidiwilliams/buzz.git
synced 2026-10-21 05:31:23 +00:00
Add per-segment review reasons for plugins (#1600)
Some checks are pending
CI / test (macos-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build (macos-latest) (push) Waiting to run
CI / build (ubuntu-latest) (push) Waiting to run
CI / build (windows-latest) (push) Waiting to run
CI / build_wheels (macos-latest) (push) Waiting to run
CI / build_wheels (ubuntu-latest) (push) Waiting to run
CI / build_wheels (windows-latest) (push) Waiting to run
CI / publish_pypi (push) Blocked by required conditions
CI / release (macos-latest) (push) Blocked by required conditions
CI / release (windows-latest) (push) Blocked by required conditions
GitHub Pages / Deploy (push) Waiting to run
Snapcraft / build (push) Waiting to run
Snapcraft / upload-edge (push) Blocked by required conditions
Snapcraft / upload-stable (push) Blocked by required conditions
Some checks are pending
CI / test (macos-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build (macos-latest) (push) Waiting to run
CI / build (ubuntu-latest) (push) Waiting to run
CI / build (windows-latest) (push) Waiting to run
CI / build_wheels (macos-latest) (push) Waiting to run
CI / build_wheels (ubuntu-latest) (push) Waiting to run
CI / build_wheels (windows-latest) (push) Waiting to run
CI / publish_pypi (push) Blocked by required conditions
CI / release (macos-latest) (push) Blocked by required conditions
CI / release (windows-latest) (push) Blocked by required conditions
GitHub Pages / Deploy (push) Waiting to run
Snapcraft / build (push) Waiting to run
Snapcraft / upload-edge (push) Blocked by required conditions
Snapcraft / upload-stable (push) Blocked by required conditions
Co-authored-by: Raivis Dejus <raivisd@scandiweb.com>
This commit is contained in:
parent
f70a02fcd5
commit
2d7d9dd795
16 changed files with 203 additions and 89 deletions
|
|
@ -33,6 +33,7 @@ class Transcription(Entity):
|
|||
url: str | None = None
|
||||
name: str | None = None
|
||||
notes: str | None = None
|
||||
metadata: str = "[]"
|
||||
|
||||
@property
|
||||
def id_as_uuid(self):
|
||||
|
|
|
|||
|
|
@ -12,3 +12,4 @@ class TranscriptionSegment(Entity):
|
|||
transcription_id: str
|
||||
speaker: str = ""
|
||||
id: int = -1
|
||||
metadata: str = "[]"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from sqlite3 import Connection
|
||||
|
|
@ -55,9 +56,9 @@ def copy_transcriptions_from_json_to_sqlite(conn: Connection):
|
|||
"""
|
||||
INSERT INTO transcription_segment (
|
||||
end_time, start_time, text, translation,
|
||||
transcription_id, speaker
|
||||
transcription_id, speaker, metadata
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?);
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
""",
|
||||
(
|
||||
segment.end,
|
||||
|
|
@ -66,6 +67,10 @@ def copy_transcriptions_from_json_to_sqlite(conn: Connection):
|
|||
segment.translation,
|
||||
transcription_id,
|
||||
getattr(segment, "speaker", ""),
|
||||
json.dumps(
|
||||
getattr(segment, "metadata", []),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
# os.remove(cache.tasks_list_file_path)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -36,6 +37,9 @@ class TranscriptionService:
|
|||
|
||||
def update_transcription_as_completed(self, id: UUID, segments: List[Segment]):
|
||||
self.transcription_dao.update_transcription_as_completed(id)
|
||||
self._insert_segments(id, segments)
|
||||
|
||||
def _insert_segments(self, id: UUID, segments: List[Segment]):
|
||||
for segment in segments:
|
||||
self.transcription_segment_dao.insert(
|
||||
TranscriptionSegment(
|
||||
|
|
@ -45,6 +49,7 @@ class TranscriptionService:
|
|||
translation='',
|
||||
transcription_id=str(id),
|
||||
speaker=segment.speaker,
|
||||
metadata=json.dumps(segment.metadata, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -62,17 +67,7 @@ class TranscriptionService:
|
|||
|
||||
def update_transcription_as_skipped(self, id: UUID, segments: List[Segment]):
|
||||
self.transcription_dao.update_transcription_as_skipped(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),
|
||||
speaker=segment.speaker,
|
||||
)
|
||||
)
|
||||
self._insert_segments(id, segments)
|
||||
|
||||
def find_completed_transcription_by_filename(self, filename: str):
|
||||
return self.transcription_dao.find_completed_transcription_by_filename(filename)
|
||||
|
|
@ -82,17 +77,7 @@ 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),
|
||||
speaker=segment.speaker,
|
||||
)
|
||||
)
|
||||
self._insert_segments(id, segments)
|
||||
|
||||
def get_transcription_segments(self, transcription_id: UUID):
|
||||
return self.transcription_segment_dao.get_segments(transcription_id)
|
||||
|
|
|
|||
|
|
@ -158,6 +158,10 @@ class BuzzPlugin:
|
|||
) -> List["Segment"]:
|
||||
"""Modify or replace the result segments before they are saved.
|
||||
|
||||
Append ``{"key": "value"}`` dicts to a segment's ``metadata`` list to
|
||||
attach extra information to that row without changing or deleting its
|
||||
text. The transcription viewer shows it as a ``key: value`` tooltip.
|
||||
|
||||
Must return a list of segments (return the input unchanged to do nothing).
|
||||
Runs on a background thread.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ CREATE TABLE transcription (
|
|||
word_level_timings BOOLEAN DEFAULT FALSE,
|
||||
extract_speech BOOLEAN DEFAULT FALSE,
|
||||
name TEXT,
|
||||
notes TEXT
|
||||
notes TEXT,
|
||||
metadata TEXT DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE transcription_segment (
|
||||
|
|
@ -30,6 +31,7 @@ CREATE TABLE transcription_segment (
|
|||
translation TEXT DEFAULT '',
|
||||
transcription_id TEXT,
|
||||
speaker TEXT DEFAULT '',
|
||||
metadata TEXT DEFAULT '[]',
|
||||
FOREIGN KEY (transcription_id) REFERENCES transcription(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_transcription_id ON transcription_segment(transcription_id);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import os
|
|||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from random import randint
|
||||
from typing import List, Optional, Tuple, Set
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
|
||||
from dataclasses_json import dataclass_json, config, Exclude
|
||||
|
||||
|
|
@ -35,6 +35,7 @@ class Segment:
|
|||
text: str
|
||||
translation: str = ""
|
||||
speaker: str = ""
|
||||
metadata: List[Dict[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
LANGUAGES = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import enum
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
|
@ -51,6 +52,7 @@ class Column(enum.Enum):
|
|||
TRANSLATION = enum.auto()
|
||||
TRANSCRIPTION_ID = enum.auto()
|
||||
SPEAKER = enum.auto()
|
||||
METADATA = enum.auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -334,6 +336,39 @@ class TranscriptionSegmentModel(QSqlTableModel):
|
|||
self.setEditStrategy(QSqlTableModel.EditStrategy.OnFieldChange)
|
||||
self.setFilter(f"transcription_id = '{transcription_id}'")
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||||
if index.isValid() and role == Qt.ItemDataRole.ToolTipRole:
|
||||
metadata = self._metadata(index.row())
|
||||
if metadata:
|
||||
return "\n".join(f"{key}: {value}" for key, value in metadata)
|
||||
|
||||
return super().data(index, role)
|
||||
|
||||
def _metadata(self, row: int) -> list[tuple[str, str]]:
|
||||
"""Return the segment metadata as a flat list of key-value pairs."""
|
||||
raw_metadata = self.record(row).value("metadata")
|
||||
if not raw_metadata:
|
||||
return []
|
||||
|
||||
try:
|
||||
metadata = json.loads(raw_metadata)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
if not isinstance(metadata, list):
|
||||
return []
|
||||
|
||||
pairs = []
|
||||
for entry in metadata:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
for key, value in entry.items():
|
||||
key = str(key).strip()
|
||||
value = str(value).strip()
|
||||
if key:
|
||||
pairs.append((key, value))
|
||||
return pairs
|
||||
|
||||
|
||||
class TranscriptionSegmentsEditorWidget(QTableView):
|
||||
PARENT_PADDINGS = 40
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Buzz can use any number of custom [Whisper.cpp](https://github.com/ggerganov/whi
|
|||
models in addition to the built-in ones. This is useful when you have several
|
||||
fine-tuned models — for example a fast model you use by default and a more accurate
|
||||
model you fall back to for difficult recordings — and want to switch between them
|
||||
without re-downloading anything.
|
||||
without re-downloading anything. This feature is available since 1.4.6.
|
||||
|
||||
## Adding a custom model
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ For Whisper.cpp you can also add any number of custom models. Enter a name and t
|
|||
add a model file that is already on your computer or download one from a URL to its `.bin`
|
||||
file (use the link from the "download" button on Huggingface). Each custom model appears by
|
||||
name in the model list so you can switch between them. See
|
||||
[Custom Whisper.cpp models](./custom_whisper_cpp_models.md) for details.
|
||||
[Custom Whisper.cpp models](./custom_whisper_cpp_models.md) for details. This feature is available since 1.4.6.
|
||||
|
||||
To improve transcription speed and memory usage you can select a quantized version of some
|
||||
larger model. For example `q_5` version. Whisper.cpp base models in different quantizations are [available here](https://huggingface.co/ggerganov/whisper.cpp/tree/main). See also [custom models](https://github.com/chidiwilliams/buzz/discussions/866) discussion page for custom models in different languages.
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ from buzz.widgets.application import Application
|
|||
|
||||
|
||||
@pytest.fixture()
|
||||
def db() -> QSqlDatabase:
|
||||
def db(qapp) -> QSqlDatabase:
|
||||
# Qt's SQL module needs a QCoreApplication to be alive; opening a
|
||||
# QSqlDatabase without one segfaults, so depend on the app fixture even
|
||||
# though these tests never touch a widget.
|
||||
db = setup_test_db()
|
||||
yield db
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -1,57 +1,11 @@
|
|||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import UUID, uuid4
|
||||
from PyQt6.QtSql import QSqlDatabase, QSqlQuery
|
||||
from PyQt6.QtSql import QSqlQuery
|
||||
|
||||
from buzz.db.dao.transcription_dao import TranscriptionDAO
|
||||
from buzz.db.entity.transcription import Transcription
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
"""Create an in-memory SQLite database for testing"""
|
||||
db = QSqlDatabase.addDatabase("QSQLITE")
|
||||
db.setDatabaseName(":memory:")
|
||||
assert db.open()
|
||||
|
||||
# Create the transcription table with the new schema
|
||||
query = QSqlQuery(db)
|
||||
query.exec("""
|
||||
CREATE TABLE transcription (
|
||||
id TEXT PRIMARY KEY,
|
||||
error_message TEXT,
|
||||
export_formats TEXT,
|
||||
file TEXT,
|
||||
output_folder TEXT,
|
||||
progress DOUBLE PRECISION DEFAULT 0.0,
|
||||
language TEXT,
|
||||
model_type TEXT,
|
||||
source TEXT,
|
||||
status TEXT,
|
||||
task TEXT,
|
||||
time_ended TIMESTAMP,
|
||||
time_queued TIMESTAMP NOT NULL,
|
||||
time_started TIMESTAMP,
|
||||
url TEXT,
|
||||
whisper_model_size TEXT,
|
||||
hugging_face_model_id TEXT,
|
||||
word_level_timings BOOLEAN DEFAULT FALSE,
|
||||
extract_speech BOOLEAN DEFAULT FALSE,
|
||||
name TEXT,
|
||||
notes TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transcription_dao(db):
|
||||
"""Create a TranscriptionDAO instance for testing"""
|
||||
return TranscriptionDAO(db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_transcription():
|
||||
"""Create a sample transcription for testing"""
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import pytest
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from buzz.db.service.transcription_service import TranscriptionService
|
||||
from buzz.db.entity.transcription import Transcription
|
||||
from buzz.transcriber.transcriber import Segment
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -40,6 +43,35 @@ def sample_transcription():
|
|||
|
||||
|
||||
class TestTranscriptionService:
|
||||
@pytest.mark.parametrize(
|
||||
"method_name",
|
||||
[
|
||||
"update_transcription_as_completed",
|
||||
"update_transcription_as_skipped",
|
||||
"replace_transcription_segments",
|
||||
],
|
||||
)
|
||||
def test_segment_metadata_is_persisted(
|
||||
self,
|
||||
transcription_service,
|
||||
mock_transcription_segment_dao,
|
||||
method_name,
|
||||
):
|
||||
segment = Segment(
|
||||
start=100,
|
||||
end=500,
|
||||
text="Repeated phrase",
|
||||
metadata=[
|
||||
{"review_reason": "Possible repetition loop"},
|
||||
{"confidence": "0.21"},
|
||||
],
|
||||
)
|
||||
|
||||
getattr(transcription_service, method_name)(uuid4(), [segment])
|
||||
|
||||
saved_segment = mock_transcription_segment_dao.insert.call_args.args[0]
|
||||
assert json.loads(saved_segment.metadata) == segment.metadata
|
||||
|
||||
def test_update_transcription_name(self, transcription_service, mock_transcription_dao):
|
||||
"""Test updating transcription name through service"""
|
||||
transcription_id = uuid4()
|
||||
|
|
|
|||
|
|
@ -60,3 +60,48 @@ def test_speaker_column_migration_preserves_existing_transcript_text(tmp_path):
|
|||
).fetchone()
|
||||
connection.close()
|
||||
assert row == ("Nyomi: keep this exact text", "")
|
||||
|
||||
|
||||
def test_metadata_migration_preserves_existing_transcript_text(tmp_path):
|
||||
database_path = tmp_path / "old-buzz.sqlite"
|
||||
connection = sqlite3.connect(database_path)
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE transcription (
|
||||
id TEXT PRIMARY KEY,
|
||||
time_queued TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE transcription_segment (
|
||||
id INTEGER PRIMARY KEY,
|
||||
end_time INT DEFAULT 0,
|
||||
start_time INT DEFAULT 0,
|
||||
text TEXT NOT NULL,
|
||||
translation TEXT DEFAULT '',
|
||||
transcription_id TEXT,
|
||||
speaker TEXT DEFAULT '',
|
||||
FOREIGN KEY (transcription_id) REFERENCES transcription(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_transcription_id
|
||||
ON transcription_segment(transcription_id);
|
||||
INSERT INTO transcription (id, time_queued)
|
||||
VALUES ('transcript-1', '2026-08-30');
|
||||
INSERT INTO transcription_segment (
|
||||
id, start_time, end_time, text, transcription_id
|
||||
) VALUES (
|
||||
1, 0, 1000, 'Keep this exact text', 'transcript-1'
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
run_sqlite_migrations(connection)
|
||||
|
||||
segment_row = connection.execute(
|
||||
"SELECT text, metadata FROM transcription_segment WHERE id = 1"
|
||||
).fetchone()
|
||||
transcription_row = connection.execute(
|
||||
"SELECT metadata FROM transcription WHERE id = 'transcript-1'"
|
||||
).fetchone()
|
||||
connection.close()
|
||||
assert segment_row == ("Keep this exact text", "[]")
|
||||
assert transcription_row == ("[]",)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,18 @@ class TestModelsPreferencesWidget:
|
|||
if path and os.path.isfile(path):
|
||||
os.remove(path)
|
||||
|
||||
@staticmethod
|
||||
def get_child_names(group_item) -> list[str]:
|
||||
return [group_item.child(i).text(0) for i in range(group_item.childCount())]
|
||||
|
||||
@classmethod
|
||||
def get_child_named(cls, group_item, name: str):
|
||||
for i in range(group_item.childCount()):
|
||||
child = group_item.child(i)
|
||||
if child.text(0) == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
def test_should_show_model_list(self, qtbot):
|
||||
widget = ModelsPreferencesWidget()
|
||||
qtbot.add_widget(widget)
|
||||
|
|
@ -61,8 +73,10 @@ class TestModelsPreferencesWidget:
|
|||
available_item = widget.model_list_widget.topLevelItem(1)
|
||||
assert available_item.text(0) == _("Available for Download")
|
||||
|
||||
tiny_item = available_item.child(0)
|
||||
assert tiny_item.text(0) == "Tiny"
|
||||
# Other model sizes may already be cached on the machine running the
|
||||
# tests, so look Tiny up by name instead of assuming a list position.
|
||||
tiny_item = self.get_child_named(available_item, "Tiny")
|
||||
assert tiny_item is not None
|
||||
tiny_item.setSelected(True)
|
||||
|
||||
download_button = widget.findChild(QPushButton, "DownloadButton")
|
||||
|
|
@ -75,14 +89,10 @@ class TestModelsPreferencesWidget:
|
|||
assert not download_button.isVisible()
|
||||
|
||||
_downloaded_item = widget.model_list_widget.topLevelItem(0)
|
||||
assert _downloaded_item.childCount() > 0
|
||||
assert _downloaded_item.child(0).text(0) == "Tiny"
|
||||
assert "Tiny" in self.get_child_names(_downloaded_item)
|
||||
|
||||
_available_item = widget.model_list_widget.topLevelItem(1)
|
||||
assert (
|
||||
_available_item.childCount() == 0
|
||||
or _available_item.child(0).text(0) != "Tiny"
|
||||
)
|
||||
assert "Tiny" not in self.get_child_names(_available_item)
|
||||
|
||||
assert os.path.isfile(widget.model.get_local_model_path())
|
||||
|
||||
|
|
@ -97,11 +107,11 @@ class TestModelsPreferencesWidget:
|
|||
widget.show()
|
||||
qtbot.add_widget(widget)
|
||||
|
||||
available_item = widget.model_list_widget.topLevelItem(0)
|
||||
assert available_item.text(0) == _("Downloaded")
|
||||
downloaded_item = widget.model_list_widget.topLevelItem(0)
|
||||
assert downloaded_item.text(0) == _("Downloaded")
|
||||
|
||||
tiny_item = available_item.child(0)
|
||||
assert tiny_item.text(0) == "Tiny"
|
||||
tiny_item = self.get_child_named(downloaded_item, "Tiny")
|
||||
assert tiny_item is not None
|
||||
tiny_item.setSelected(True)
|
||||
|
||||
delete_button = widget.findChild(QPushButton, "DeleteButton")
|
||||
|
|
|
|||
|
|
@ -272,6 +272,42 @@ class TestTranscriptionSegmentModel:
|
|||
assert model.tableName() == "transcription_segment"
|
||||
assert model.editStrategy() == model.EditStrategy.OnFieldChange
|
||||
|
||||
def test_metadata_is_shown_as_key_value_tooltip(
|
||||
self,
|
||||
transcription_dao,
|
||||
transcription_segment_dao,
|
||||
):
|
||||
transcription_id = uuid.uuid4()
|
||||
transcription_dao.insert(
|
||||
Transcription(
|
||||
id=str(transcription_id),
|
||||
status="completed",
|
||||
file=test_audio_path,
|
||||
task=Task.TRANSCRIBE.value,
|
||||
model_type=ModelType.WHISPER.value,
|
||||
whisper_model_size=WhisperModelSize.TINY.value,
|
||||
)
|
||||
)
|
||||
transcription_segment_dao.insert(
|
||||
TranscriptionSegment(
|
||||
40,
|
||||
299,
|
||||
"Repeated phrase",
|
||||
"",
|
||||
str(transcription_id),
|
||||
metadata='[{"review_reason": "Possible repetition loop"}, '
|
||||
'{"confidence": "0.21"}]',
|
||||
)
|
||||
)
|
||||
model = TranscriptionSegmentModel(transcription_id)
|
||||
model.select()
|
||||
index = model.index(0, Column.TEXT.value)
|
||||
|
||||
tooltip = model.data(index, Qt.ItemDataRole.ToolTipRole)
|
||||
assert tooltip == (
|
||||
"review_reason: Possible repetition loop\nconfidence: 0.21"
|
||||
)
|
||||
|
||||
|
||||
class TestTranscriptionSegmentsEditorWidget:
|
||||
"""Test the TranscriptionSegmentsEditorWidget"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue