From 56b95c46094347677b380cda044b63203e190e85 Mon Sep 17 00:00:00 2001 From: Yutsuo Date: Sun, 30 Aug 2026 21:24:01 -0300 Subject: [PATCH] feat: add responsive transcription job interface --- .../services/preprocessing.py | 4 +- .../services/transcription.py | 23 ++++++++ src/voice_transcriptor/ui/main_window.py | 53 ++++++++++++++++--- tests/test_transcription.py | 13 ++++- tests/ui/test_main_window.py | 44 ++++++++++++++- 5 files changed, 127 insertions(+), 10 deletions(-) diff --git a/src/voice_transcriptor/services/preprocessing.py b/src/voice_transcriptor/services/preprocessing.py index 74fda56..6ba1204 100644 --- a/src/voice_transcriptor/services/preprocessing.py +++ b/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"]) diff --git a/src/voice_transcriptor/services/transcription.py b/src/voice_transcriptor/services/transcription.py index 544cf1a..90ffe54 100644 --- a/src/voice_transcriptor/services/transcription.py +++ b/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 diff --git a/src/voice_transcriptor/ui/main_window.py b/src/voice_transcriptor/ui/main_window.py index 2364ac6..60c907e 100644 --- a/src/voice_transcriptor/ui/main_window.py +++ b/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: - self.progress_bar.setValue(progress.percent); self.log.appendPlainText(progress.message) + 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() diff --git a/tests/test_transcription.py b/tests/test_transcription.py index a294abc..cffa8b4 100644 --- a/tests/test_transcription.py +++ b/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 diff --git a/tests/ui/test_main_window.py b/tests/ui/test_main_window.py index 24595dd..f9f3f95 100644 --- a/tests/ui/test_main_window.py +++ b/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()