Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions buzz/db/entity/transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions buzz/db/entity/transcription_segment.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ class TranscriptionSegment(Entity):
transcription_id: str
speaker: str = ""
id: int = -1
metadata: str = "[]"
9 changes: 7 additions & 2 deletions buzz/db/helpers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
from datetime import datetime
from sqlite3 import Connection
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
29 changes: 7 additions & 22 deletions buzz/db/service/transcription_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from typing import List
from uuid import UUID

Expand Down Expand Up @@ -36,6 +37,9 @@ def update_transcription_progress(self, id: UUID, progress: float):

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(
Expand All @@ -45,6 +49,7 @@ def update_transcription_as_completed(self, id: UUID, segments: List[Segment]):
translation='',
transcription_id=str(id),
speaker=segment.speaker,
metadata=json.dumps(segment.metadata, ensure_ascii=False),
)
)

Expand All @@ -62,17 +67,7 @@ def update_transcription_language(self, id: UUID, language: str):

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)
Expand All @@ -82,17 +77,7 @@ def reset_transcription_for_restart(self, id: UUID):

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)
Expand Down
4 changes: 4 additions & 0 deletions buzz/plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ def after_transcription(
) -> 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.
"""
Expand Down
4 changes: 3 additions & 1 deletion buzz/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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);
3 changes: 2 additions & 1 deletion buzz/transcriber/transcriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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

Expand Down Expand Up @@ -35,6 +35,7 @@ class Segment:
text: str
translation: str = ""
speaker: str = ""
metadata: List[Dict[str, str]] = field(default_factory=list)


LANGUAGES = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import enum
import json
import logging
from dataclasses import dataclass
from typing import Optional
Expand Down Expand Up @@ -51,6 +52,7 @@ class Column(enum.Enum):
TRANSLATION = enum.auto()
TRANSCRIPTION_ID = enum.auto()
SPEAKER = enum.auto()
METADATA = enum.auto()


@dataclass
Expand Down Expand Up @@ -334,6 +336,39 @@ def __init__(self, transcription_id: UUID):
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
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/custom_whisper_cpp_models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@


@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()
Expand Down
48 changes: 1 addition & 47 deletions tests/db/dao/transcription_dao_test.py
Original file line number Diff line number Diff line change
@@ -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"""
Expand Down
34 changes: 33 additions & 1 deletion tests/db/service/transcription_service_test.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions tests/db/speaker_schema_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ("[]",)
Loading
Loading