From e7c0ee774008bb02ae4a0e7010bae455d95a02e9 Mon Sep 17 00:00:00 2001 From: Yutsuo Date: Sun, 30 Aug 2026 21:29:24 -0300 Subject: [PATCH] docs: wire and validate OpenAI transcription --- README.md | 16 ++++++++++++---- src/voice_transcriptor/app.py | 13 +++++++++++-- tests/ui/test_app.py | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c30bca9..c94c8ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Voice Transcriptor -Windows desktop preprocessing foundation for long audio and video transcription jobs. +Windows desktop application for resumable OpenAI transcription of long audio and video recordings. ## Setup and run @@ -15,9 +15,17 @@ The app accepts `.m4a`, `.mp3`, `.wav`, `.mp4`, `.mov`, `.webm`, and `.mkv`. It Chunking is duration-based, not tied to a presumed universal upload-size limit. Defaults are 15-minute chunks with 15 seconds of overlap. Change duration, overlap, and temporary-file retention under **Settings → Advanced**. -Each job writes an exact-timestamp JSON manifest in its job-specific temporary directory. Files are removed after completion, cancellation, or failure unless retention is enabled for debugging. Active preprocessing can be cancelled from the main window. +Each transcription job is stored under `/voice-transcriptor-jobs//`. Its atomic JSON manifest retains the exact source start/end time of every chunk plus `pending`, `processing`, `completed`, or `failed` status and raw transcript text. Successful chunks are saved immediately. Restarting the same source with the same effective settings resumes an incomplete job and never retranscribes completed chunks. -This milestone prepares media only. It does not call a transcription API or upload chunks. +The default model is `gpt-transcribe`. Settings provides an editable selector seeded with `gpt-transcribe`, `gpt-4o-transcribe`, and `gpt-4o-mini-transcribe`, so future compatible transcription model IDs can be entered. The current official API documentation may reject `gpt-transcribe` for accounts where that identifier is unavailable; the application reports that as a permanent API error without silently changing models. + +Language defaults to Brazilian Portuguese. The app sends ISO language `pt` and a prompt that preserves the spoken language without translation, Brazilian spelling and punctuation, numbers, names, technical terminology, and acronyms. Add names and specialized vocabulary in **Settings → Context / Vocabulary**. + +Every preprocessed chunk is submitted independently to the OpenAI Audio Transcriptions API. Raw chunk transcripts are concatenated in source order; overlap deduplication is intentionally not implemented yet. Rate limits, timeouts, connection errors, and server errors use finite exponential backoff. Permanent API failures are not retried indefinitely. Active preprocessing, retry waits, and transcription can be cancelled without blocking the GUI. + +## API key + +Store the API key as a Windows Credential Manager generic credential whose target name is exactly `OPENAI_API_KEY`. The application retrieves that existing target through the Windows keyring backend. Saving a replacement key from Settings updates the same target. API keys and authorization headers are never written to settings, job manifests, transcripts, or logs. ## Tests @@ -26,4 +34,4 @@ python -m pytest -v python -m compileall -q src tests ``` -The OpenAI API key is stored through `keyring` rather than in the JSON settings file. +Tests use fake API endpoints and do not make live OpenAI requests. diff --git a/src/voice_transcriptor/app.py b/src/voice_transcriptor/app.py index 9858998..e1c2995 100644 --- a/src/voice_transcriptor/app.py +++ b/src/voice_transcriptor/app.py @@ -3,19 +3,28 @@ from __future__ import annotations import sys from PySide6.QtWidgets import QApplication +from openai import OpenAI from voice_transcriptor.services.credentials import CredentialService from voice_transcriptor.services.media_probe import MediaProbeService, detect_tools from voice_transcriptor.services.preprocessing import PreprocessingService +from voice_transcriptor.services.job_manifest import JobManifestRepository from voice_transcriptor.services.settings import SettingsRepository +from voice_transcriptor.services.transcription import OpenAITranscriptionClient, TranscriptionService from voice_transcriptor.ui.main_window import MainWindow def create_main_window() -> MainWindow: tools = detect_tools() probe = MediaProbeService(tools.ffprobe_path) if tools.ffprobe_path else _UnavailableProbe() - preprocessing = PreprocessingService(tools.ffmpeg_path, probe) if tools.ffmpeg_path else _UnavailablePreprocessing() - return MainWindow(probe, preprocessing, SettingsRepository(), CredentialService()) + manifests = JobManifestRepository() + preprocessing = PreprocessingService(tools.ffmpeg_path, probe, manifest_repository=manifests) if tools.ffmpeg_path else _UnavailablePreprocessing() + + def transcription_factory(api_key: str) -> TranscriptionService: + client = OpenAI(api_key=api_key, max_retries=0) + return TranscriptionService(OpenAITranscriptionClient(client), manifests) + + return MainWindow(probe, preprocessing, SettingsRepository(), CredentialService(), transcription_factory) class _UnavailableProbe: diff --git a/tests/ui/test_app.py b/tests/ui/test_app.py index 6971bfc..0db901e 100644 --- a/tests/ui/test_app.py +++ b/tests/ui/test_app.py @@ -4,3 +4,23 @@ def test_concrete_window_starts_offscreen(qtbot, monkeypatch) -> None: qtbot.addWidget(window) window.show() assert window.windowTitle() == "Voice Transcriptor" + + +def test_openai_client_is_created_lazily_with_sdk_retries_disabled(qtbot, monkeypatch) -> None: + from voice_transcriptor import app + calls = [] + + class FakeOpenAI: + def __init__(self, **kwargs): + calls.append(kwargs) + self.audio = type("Audio", (), {"transcriptions": object()})() + + monkeypatch.setattr(app, "OpenAI", FakeOpenAI) + window = app.create_main_window() + qtbot.addWidget(window) + + assert calls == [] + service = window.transcription_factory("secret-value") + + assert calls == [{"api_key": "secret-value", "max_retries": 0}] + assert service.client.client.audio.transcriptions is not None