|
|
|
@ -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.formatting import format_duration, format_file_size |
|
|
|
from voice_transcriptor.services.preprocessing import CancellationToken, PreprocessingCancelled, PreprocessingOptions, PreprocessingProgress, SUPPORTED_EXTENSIONS |
|
|
|
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 |
|
|
|
from voice_transcriptor.ui.settings_dialog import SettingsDialog |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -33,11 +34,40 @@ class PreprocessingWorker(QRunnable): |
|
|
|
self.signals.completed.emit(result) |
|
|
|
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): |
|
|
|
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) |
|
|
|
super().__init__(parent) |
|
|
|
self.media_probe = media_probe; self.preprocessing_service = preprocessing_service |
|
|
|
self.media_probe = media_probe; self.preprocessing_service = preprocessing_service |
|
|
|
self.settings_repository = settings_repository; self.credentials = credentials |
|
|
|
self.settings_repository = settings_repository; self.credentials = credentials |
|
|
|
|
|
|
|
self.transcription_factory = transcription_factory |
|
|
|
self.settings, warning = settings_repository.load() |
|
|
|
self.settings, warning = settings_repository.load() |
|
|
|
self.selected_media = None; self._worker = None; self._cancellation_token = CancellationToken() |
|
|
|
self.selected_media = None; self._worker = None; self._cancellation_token = CancellationToken() |
|
|
|
self.setWindowTitle("Voice Transcriptor"); self.setMinimumSize(650, 430); self.setAcceptDrops(True) |
|
|
|
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) |
|
|
|
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.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) |
|
|
|
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.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) |
|
|
|
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) |
|
|
|
if warning: self.log.appendPlainText(warning) |
|
|
|
@ -73,7 +104,7 @@ class MainWindow(QMainWindow): |
|
|
|
if self.selected_media is None: return |
|
|
|
if self.selected_media is None: return |
|
|
|
self._begin_busy_state() |
|
|
|
self._begin_busy_state() |
|
|
|
options = PreprocessingOptions(self.settings.chunk_duration_seconds, self.settings.chunk_overlap_seconds, self.settings.retain_temporary_files) |
|
|
|
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 |
|
|
|
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) |
|
|
|
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) |
|
|
|
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…") |
|
|
|
self._cancellation_token.cancel(); self.cancel_button.setEnabled(False); self.log.appendPlainText("Cancelling preprocessing…") |
|
|
|
|
|
|
|
|
|
|
|
@Slot(object) |
|
|
|
@Slot(object) |
|
|
|
def _on_progress(self, progress: PreprocessingProgress) -> None: |
|
|
|
def _on_progress(self, progress: PreprocessingProgress | TranscriptionProgress) -> None: |
|
|
|
self.progress_bar.setValue(progress.percent); self.log.appendPlainText(progress.message) |
|
|
|
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) |
|
|
|
@Slot(object) |
|
|
|
def _on_completed(self, result) -> None: |
|
|
|
def _on_completed(self, result) -> None: |
|
|
|
self.progress_bar.setValue(100) |
|
|
|
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.") |
|
|
|
else: result.cleanup(); self.log.appendPlainText("Audio preprocessing complete.") |
|
|
|
self._finish_busy_state() |
|
|
|
self._finish_busy_state() |
|
|
|
|
|
|
|
|
|
|
|
|