Browse Source

feat: add responsive transcription job interface

master
Yutsuo 4 days ago
parent
commit
56b95c4609
  1. 4
      src/voice_transcriptor/services/preprocessing.py
  2. 23
      src/voice_transcriptor/services/transcription.py
  3. 51
      src/voice_transcriptor/ui/main_window.py
  4. 13
      tests/test_transcription.py
  5. 44
      tests/ui/test_main_window.py

4
src/voice_transcriptor/services/preprocessing.py

@ -69,7 +69,7 @@ class PreprocessingService:
self.process_factory = process_factory
self.manifest_repository = manifest_repository or JobManifestRepository()
def preprocess(self, source: Path, options: PreprocessingOptions, token: CancellationToken | None = None, progress: Callable[[PreprocessingProgress], None] | None = None, durable_root: Path | None = None) -> PreprocessingResult:
def preprocess(self, source: Path, options: PreprocessingOptions, token: CancellationToken | None = None, progress: Callable[[PreprocessingProgress], None] | None = None, durable_root: Path | None = None, transcription_settings: dict | None = None) -> PreprocessingResult:
token = token or CancellationToken()
source = source.resolve()
if source.suffix.lower() not in SUPPORTED_EXTENSIONS: raise PreprocessingError("Unsupported media format.")
@ -98,7 +98,7 @@ class PreprocessingService:
created = self.manifest_repository.create(
job,
manifest["source"],
{**manifest["settings"], "model": None, "language": None},
{**manifest["settings"], **(transcription_settings or {})},
chunk_items,
)
manifest_path = Path(created["manifest_path"])

23
src/voice_transcriptor/services/transcription.py

@ -54,6 +54,29 @@ def build_prompt(context: str) -> str:
return f"{BRAZILIAN_PROMPT}\n\n{extra}" if extra else BRAZILIAN_PROMPT
def find_resumable_manifest(output_directory: Path, source: Path, settings: AppSettings) -> Path | None:
root = output_directory / "voice-transcriptor-jobs"
candidates = root.rglob("manifest.json") if root.is_dir() else output_directory.rglob("manifest.json")
expected_source = str(source.resolve())
expected_settings = {
"model": settings.model,
"language": settings.language,
"context_vocabulary": settings.context_vocabulary,
}
matches: list[tuple[str, Path]] = []
for path in candidates:
try:
manifest = JobManifestRepository().load(path)
except Exception:
continue
actual = manifest.get("settings", {})
if manifest.get("state") == "completed" or manifest.get("source", {}).get("path") != expected_source:
continue
if all(actual.get(key) == value for key, value in expected_settings.items()):
matches.append((str(manifest.get("updated_at", "")), path))
return max(matches, default=("", None), key=lambda value: value[0])[1]
class OpenAITranscriptionClient:
def __init__(self, client) -> None: self.client = client

51
src/voice_transcriptor/ui/main_window.py

@ -7,6 +7,7 @@ from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QMainWindow, QPl
from voice_transcriptor.formatting import format_duration, format_file_size
from voice_transcriptor.services.preprocessing import CancellationToken, PreprocessingCancelled, PreprocessingOptions, PreprocessingProgress, SUPPORTED_EXTENSIONS
from voice_transcriptor.services.transcription import TranscriptionProgress, find_resumable_manifest
from voice_transcriptor.ui.settings_dialog import SettingsDialog
@ -33,11 +34,40 @@ class PreprocessingWorker(QRunnable):
self.signals.completed.emit(result)
class JobWorker(QRunnable):
def __init__(self, preprocessing_service, transcription_factory, credentials, source: Path, settings, token: CancellationToken) -> None:
super().__init__(); self.preprocessing_service = preprocessing_service; self.transcription_factory = transcription_factory; self.credentials = credentials; self.source = source; self.settings = settings; self.token = token; self.signals = WorkerSignals()
@Slot()
def run(self) -> None:
try:
self.token.raise_if_cancelled()
api_key = self.credentials.get_api_key()
if not api_key: raise RuntimeError("OpenAI API key is not configured in Windows Credential Manager.")
manifest_path = find_resumable_manifest(self.settings.output_directory, self.source, self.settings)
if manifest_path is None:
options = PreprocessingOptions(self.settings.chunk_duration_seconds, self.settings.chunk_overlap_seconds, True)
result = self.preprocessing_service.preprocess(
self.source, options, self.token, self.signals.progress.emit,
durable_root=self.settings.output_directory,
transcription_settings={"model": self.settings.model, "language": self.settings.language, "context_vocabulary": self.settings.context_vocabulary},
)
manifest_path = result.manifest_path
transcript = self.transcription_factory(api_key).run(manifest_path, self.settings, self.token, self.signals.progress.emit)
except PreprocessingCancelled:
self.signals.cancelled.emit()
except Exception as exc:
self.signals.failed.emit(str(exc))
else:
self.signals.completed.emit(transcript)
class MainWindow(QMainWindow):
def __init__(self, media_probe, preprocessing_service, settings_repository, credentials, parent=None) -> None:
def __init__(self, media_probe, preprocessing_service, settings_repository, credentials, transcription_factory=None, parent=None) -> None:
super().__init__(parent)
self.media_probe = media_probe; self.preprocessing_service = preprocessing_service
self.settings_repository = settings_repository; self.credentials = credentials
self.transcription_factory = transcription_factory
self.settings, warning = settings_repository.load()
self.selected_media = None; self._worker = None; self._cancellation_token = CancellationToken()
self.setWindowTitle("Voice Transcriptor"); self.setMinimumSize(650, 430); self.setAcceptDrops(True)
@ -46,7 +76,8 @@ class MainWindow(QMainWindow):
row = QHBoxLayout(); self.browse_button = QPushButton("Browse…", central); self.settings_button = QPushButton("Settings", central); row.addWidget(self.browse_button); row.addWidget(self.settings_button); layout.addLayout(row)
self.metadata_label = QLabel("Select a supported audio or video file.", central); layout.addWidget(self.metadata_label)
self.progress_bar = QProgressBar(central); self.progress_bar.setRange(0, 100); self.progress_bar.setValue(0); layout.addWidget(self.progress_bar)
actions = QHBoxLayout(); self.prepare_button = QPushButton("Prepare audio", central); self.prepare_button.setEnabled(False); self.cancel_button = QPushButton("Cancel", central); self.cancel_button.setEnabled(False); actions.addWidget(self.prepare_button); actions.addWidget(self.cancel_button); layout.addLayout(actions)
progress_details = QHBoxLayout(); self.chunk_progress_label = QLabel("0 / 0 chunks", central); self.current_chunk_label = QLabel("Current chunk: —", central); self.elapsed_label = QLabel("Elapsed: 00:00:00", central); progress_details.addWidget(self.chunk_progress_label); progress_details.addWidget(self.current_chunk_label); progress_details.addWidget(self.elapsed_label); layout.addLayout(progress_details)
actions = QHBoxLayout(); self.prepare_button = QPushButton("Transcribe", central); self.prepare_button.setEnabled(False); self.cancel_button = QPushButton("Cancel", central); self.cancel_button.setEnabled(False); actions.addWidget(self.prepare_button); actions.addWidget(self.cancel_button); layout.addLayout(actions)
self.log = QPlainTextEdit(central); self.log.setReadOnly(True); layout.addWidget(self.log); self.setCentralWidget(central)
self.browse_button.clicked.connect(self.browse); self.settings_button.clicked.connect(self.open_settings); self.prepare_button.clicked.connect(self.start_preprocessing); self.cancel_button.clicked.connect(self.cancel_preprocessing)
if warning: self.log.appendPlainText(warning)
@ -73,7 +104,7 @@ class MainWindow(QMainWindow):
if self.selected_media is None: return
self._begin_busy_state()
options = PreprocessingOptions(self.settings.chunk_duration_seconds, self.settings.chunk_overlap_seconds, self.settings.retain_temporary_files)
worker = PreprocessingWorker(self.preprocessing_service, self.selected_media.path, options, self._cancellation_token)
worker = JobWorker(self.preprocessing_service, self.transcription_factory, self.credentials, self.selected_media.path, self.settings, self._cancellation_token) if self.transcription_factory else PreprocessingWorker(self.preprocessing_service, self.selected_media.path, options, self._cancellation_token)
self._worker = worker
worker.signals.progress.connect(self._on_progress); worker.signals.completed.connect(self._on_completed); worker.signals.cancelled.connect(self._on_cancelled); worker.signals.failed.connect(self._on_failed)
QThreadPool.globalInstance().start(worker)
@ -85,13 +116,23 @@ class MainWindow(QMainWindow):
self._cancellation_token.cancel(); self.cancel_button.setEnabled(False); self.log.appendPlainText("Cancelling preprocessing…")
@Slot(object)
def _on_progress(self, progress: PreprocessingProgress) -> None:
def _on_progress(self, progress: PreprocessingProgress | TranscriptionProgress) -> None:
if isinstance(progress, TranscriptionProgress):
percent = int(progress.completed * 100 / progress.total) if progress.total else 0
self.progress_bar.setValue(percent)
self.chunk_progress_label.setText(f"{progress.completed} / {progress.total} chunks")
self.current_chunk_label.setText(f"Current chunk: {progress.current_chunk if progress.current_chunk is not None else ''}")
self.elapsed_label.setText(f"Elapsed: {format_duration(progress.elapsed_seconds)}")
self.log.appendPlainText(progress.message)
if progress.api_error: self.log.appendPlainText(progress.api_error)
else:
self.progress_bar.setValue(progress.percent); self.log.appendPlainText(progress.message)
@Slot(object)
def _on_completed(self, result) -> None:
self.progress_bar.setValue(100)
if result.retained: self.log.appendPlainText(f"Prepared chunks retained at {result.job_directory}")
if isinstance(result, Path): self.log.appendPlainText(f"Transcription complete: {result}")
elif result.retained: self.log.appendPlainText(f"Prepared chunks retained at {result.job_directory}")
else: result.cleanup(); self.log.appendPlainText("Audio preprocessing complete.")
self._finish_busy_state()

13
tests/test_transcription.py

@ -6,7 +6,7 @@ import pytest
from voice_transcriptor.models import AppSettings
from voice_transcriptor.services.job_manifest import JobManifestRepository
from voice_transcriptor.services.preprocessing import CancellationToken, PreprocessingCancelled
from voice_transcriptor.services.transcription import OpenAITranscriptionClient, PermanentTranscriptionError, RetryPolicy, TranscriptionService, build_prompt, normalize_language
from voice_transcriptor.services.transcription import OpenAITranscriptionClient, PermanentTranscriptionError, RetryPolicy, TranscriptionService, build_prompt, find_resumable_manifest, normalize_language
class Endpoint:
@ -80,3 +80,14 @@ def test_cancelled_job_makes_no_api_request(tmp_path: Path) -> None:
repository, manifest_path = make_job(tmp_path); endpoint = Endpoint(["unexpected"]); token = CancellationToken(); token.cancel()
with pytest.raises(PreprocessingCancelled): TranscriptionService(OpenAITranscriptionClient(SimpleNamespace(audio=SimpleNamespace(transcriptions=endpoint))), repository).run(manifest_path, AppSettings("gpt-transcribe", "pt-BR", tmp_path), token=token)
assert endpoint.calls == []; assert repository.load(manifest_path)["state"] == "cancelled"
def test_find_resumable_manifest_matches_source_and_effective_settings(tmp_path: Path) -> None:
repository, manifest_path = make_job(tmp_path)
manifest = repository.load(manifest_path)
manifest["settings"] = {"model": "gpt-transcribe", "language": "pt-BR", "context_vocabulary": "Pix"}
repository.save(manifest_path, manifest)
settings = AppSettings("gpt-transcribe", "pt-BR", tmp_path, context_vocabulary="Pix")
assert find_resumable_manifest(tmp_path, Path(manifest["source"]["path"]), settings) == manifest_path
assert find_resumable_manifest(tmp_path, Path(manifest["source"]["path"]), AppSettings("other", "pt-BR", tmp_path, context_vocabulary="Pix")) is None

44
tests/ui/test_main_window.py

@ -1,7 +1,9 @@
from pathlib import Path
from types import SimpleNamespace
from voice_transcriptor.models import AppSettings, MediaInfo
from voice_transcriptor.ui.main_window import MainWindow
from voice_transcriptor.services.transcription import TranscriptionProgress
class Repository:
@ -12,6 +14,7 @@ class Repository:
class Credentials:
def set_api_key(self, value): pass
def get_api_key(self): return "test-key"
class Probe:
@ -25,10 +28,13 @@ class Preprocessor:
def test_main_window_exposes_progress_and_cancel_controls(qtbot, tmp_path: Path) -> None:
window = MainWindow(Probe(), Preprocessor(), Repository(tmp_path), Credentials())
qtbot.addWidget(window)
assert window.prepare_button.text() == "Prepare audio"
assert window.prepare_button.text() == "Transcribe"
assert window.cancel_button.text() == "Cancel"
assert window.cancel_button.isEnabled() is False
assert window.progress_bar.value() == 0
assert window.chunk_progress_label.text() == "0 / 0 chunks"
assert window.current_chunk_label.text() == "Current chunk: —"
assert window.elapsed_label.text() == "Elapsed: 00:00:00"
def test_select_file_populates_media_and_enables_preprocessing(qtbot, tmp_path: Path) -> None:
@ -47,3 +53,39 @@ def test_cancel_button_cancels_active_token(qtbot, tmp_path: Path) -> None:
window._begin_busy_state()
window.cancel_preprocessing()
assert window._cancellation_token.cancelled is True
def test_transcription_progress_updates_required_status_fields(qtbot, tmp_path: Path) -> None:
window = MainWindow(Probe(), Preprocessor(), Repository(tmp_path), Credentials())
qtbot.addWidget(window)
window._on_progress(TranscriptionProgress(2, 5, 3, 65.0, "retrying", "Rate limited", "OpenAI API transient error (HTTP 429)."))
assert window.chunk_progress_label.text() == "2 / 5 chunks"
assert window.current_chunk_label.text() == "Current chunk: 3"
assert window.elapsed_label.text() == "Elapsed: 00:01:05"
assert "HTTP 429" in window.log.toPlainText()
def test_transcription_job_runs_through_worker_and_restores_controls(qtbot, tmp_path: Path) -> None:
source = tmp_path / "recording.mp3"; source.write_bytes(b"x")
transcript = tmp_path / "transcript.txt"
class DurablePreprocessor:
def preprocess(self, *args, **kwargs):
manifest = tmp_path / "manifest.json"; manifest.write_text("{}", encoding="utf-8")
return SimpleNamespace(manifest_path=manifest)
class Transcriber:
def run(self, manifest_path, settings, token, progress):
progress(TranscriptionProgress(1, 1, 1, 2.0, "transcribing", "Completed chunk 1 of 1"))
transcript.write_text("Olá\n", encoding="utf-8")
return transcript
window = MainWindow(Probe(), DurablePreprocessor(), Repository(tmp_path), Credentials(), lambda key: Transcriber())
qtbot.addWidget(window); window.select_file(source); window.prepare_button.click()
qtbot.waitUntil(lambda: "Transcription complete" in window.log.toPlainText())
assert window.progress_bar.value() == 100
assert window.browse_button.isEnabled()
assert not window.cancel_button.isEnabled()

Loading…
Cancel
Save