From a1e9e857f3be0403286a874032ca6fa535fcb195 Mon Sep 17 00:00:00 2001 From: Yutsuo Date: Sun, 30 Aug 2026 16:23:35 -0300 Subject: [PATCH] feat: add audio preprocessing and chunking --- README.md | 29 +++ ...2026-08-30-audio-preprocessing-chunking.md | 189 ++++++++++++++++++ src/voice_transcriptor/__main__.py | 3 + src/voice_transcriptor/app.py | 31 +++ src/voice_transcriptor/models.py | 17 ++ src/voice_transcriptor/services/chunking.py | 54 +++++ .../services/media_probe.py | 7 +- .../services/preprocessing.py | 136 +++++++++++++ src/voice_transcriptor/services/settings.py | 15 +- src/voice_transcriptor/ui/main_window.py | 122 +++++++++++ src/voice_transcriptor/ui/settings_dialog.py | 42 +++- tests/test_chunking.py | 50 +++++ tests/test_media_probe.py | 4 + tests/test_preprocessing.py | 70 +++++++ tests/test_settings.py | 21 ++ tests/ui/test_app.py | 6 + tests/ui/test_main_window.py | 49 +++++ tests/ui/test_settings_dialog.py | 16 ++ 18 files changed, 858 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-30-audio-preprocessing-chunking.md create mode 100644 src/voice_transcriptor/__main__.py create mode 100644 src/voice_transcriptor/app.py create mode 100644 src/voice_transcriptor/services/chunking.py create mode 100644 src/voice_transcriptor/services/preprocessing.py create mode 100644 src/voice_transcriptor/ui/main_window.py create mode 100644 tests/test_chunking.py create mode 100644 tests/test_preprocessing.py create mode 100644 tests/ui/test_app.py create mode 100644 tests/ui/test_main_window.py diff --git a/README.md b/README.md index e69de29..c30bca9 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,29 @@ +# Voice Transcriptor + +Windows desktop preprocessing foundation for long audio and video transcription jobs. + +## Setup and run + +Install Python 3.12 or newer plus FFmpeg/FFprobe, ensure both executables are on `PATH`, then install dependencies and launch: + +```powershell +python -m pip install -r requirements.txt +python -m voice_transcriptor +``` + +The app accepts `.m4a`, `.mp3`, `.wav`, `.mp4`, `.mov`, `.webm`, and `.mkv`. It probes the source with FFprobe and streams it through FFmpeg into mono 24 kHz AAC-LC `.m4a` chunks at 64 kbps. Video audio is selected directly, with no large intermediate extraction file. + +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. + +This milestone prepares media only. It does not call a transcription API or upload chunks. + +## Tests + +```powershell +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. diff --git a/docs/superpowers/plans/2026-08-30-audio-preprocessing-chunking.md b/docs/superpowers/plans/2026-08-30-audio-preprocessing-chunking.md new file mode 100644 index 0000000..bd43f4c --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-audio-preprocessing-chunking.md @@ -0,0 +1,189 @@ +# Audio Preprocessing and Chunking Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a cancellable, incremental FFmpeg pipeline that creates duration-based overlapping speech-audio chunks, exact timestamp manifests, and GUI-visible progress. + +**Architecture:** Pure `Decimal` chunk calculations feed a service that probes once and launches one streaming FFmpeg process per chunk. Typed callbacks and cancellation stay UI-independent; a Qt worker adapts them into signals, while settings persistence supplies duration, overlap, and retention policy. + +**Tech Stack:** Python 3.12+, `decimal`, `dataclasses`, `subprocess`, FFmpeg/FFprobe, PySide6, pytest, pytest-qt. + +**Spec:** `docs/superpowers/specs/2026-08-30-audio-preprocessing-chunking-design.md` + +## Global Constraints + +- Accept only `m4a`, `mp3`, `wav`, `mp4`, `mov`, `webm`, and `mkv` through the preprocessing boundary. +- Use FFprobe before FFmpeg and require a positive finite duration plus an audio stream. +- Default chunk duration is 900 seconds and overlap is 15 seconds; chunking is duration-based, not based on a presumed universal upload-size limit. +- Encode direct-from-source mono 24 kHz AAC-LC at 64 kbps in `.m4a` without a large intermediate file. +- Never load the recording into Python memory; one streaming FFmpeg child may run at a time. +- Manifest timestamps use exact decimal seconds serialized as strings. +- Job artifacts live in one verified job-specific temporary directory; remove it unless retention is enabled. +- Do not call any transcription API. + +--- + +## File Map + +- `src/voice_transcriptor/models.py`: shared immutable settings/media/chunk/progress/result values. +- `src/voice_transcriptor/services/chunking.py`: pure exact chunk calculations and validation. +- `src/voice_transcriptor/services/media_probe.py`: source stream metadata from FFprobe. +- `src/voice_transcriptor/services/preprocessing.py`: process lifecycle, manifest, cancellation, progress, and cleanup. +- `src/voice_transcriptor/services/settings.py`: backward-compatible advanced settings persistence. +- `src/voice_transcriptor/ui/settings_dialog.py`: collapsible advanced preprocessing controls. +- `src/voice_transcriptor/ui/main_window.py`: selection, metadata, worker, progress, and cancellation UI. +- `src/voice_transcriptor/app.py`, `src/voice_transcriptor/__main__.py`: concrete wiring and entry points. +- `tests/test_chunking.py`: exact calculation contracts. +- `tests/test_preprocessing.py`: FFmpeg/manifest/lifecycle service contracts. +- `tests/test_media_probe.py`, `tests/test_settings.py`: extended existing contracts. +- `tests/ui/test_settings_dialog.py`, `tests/ui/test_main_window.py`, `tests/ui/test_app.py`: Qt behavior and startup. + +### Task 1: Exact chunk model and calculations + +**Files:** +- Modify: `src/voice_transcriptor/models.py` +- Create: `src/voice_transcriptor/services/chunking.py` +- Create: `tests/test_chunking.py` + +**Interfaces:** +- Produces: `ChunkBoundary(index: int, start_seconds: Decimal, end_seconds: Decimal)` with derived `duration_seconds`. +- Produces: `calculate_chunk_boundaries(total_duration, chunk_duration=Decimal("900"), overlap=Decimal("15")) -> tuple[ChunkBoundary, ...]`. +- Produces: `total_chunk_duration(boundaries) -> Decimal` and `source_timestamp(boundary, local_seconds) -> Decimal`. + +- [ ] **Step 1: Write failing literal boundary tests.** Include `600 -> [(0, 600)]`, `900 -> [(0, 900)]`, `1800 -> [(0, 900), (885, 1785), (1770, 1800)]`, `901.25 -> [(0, 900), (885, 901.25)]`, and zero-overlap `1800 -> [(0, 900), (900, 1800)]`. +- [ ] **Step 2: Write failing aggregate/offset/validation tests.** Assert the 1800-second default boundaries total `1830`, chunk two local `12.5` maps to source `897.5`, multi-hour `28800` ends exactly at `28800`, and invalid non-positive duration/chunk or overlap outside `[0, chunk)` raises `ValueError`. +- [ ] **Step 3: Run `python -m pytest tests/test_chunking.py -v` and confirm failure because the module/interfaces do not exist.** +- [ ] **Step 4: Implement exact calculation minimally.** Convert inputs with `Decimal(str(value))`, reject non-finite values, advance by `chunk - overlap`, and stop immediately when `end == total`. +- [ ] **Step 5: Run the focused tests and confirm they pass; then run the existing suite to detect model regressions.** +- [ ] **Step 6: Commit with `git commit -m "feat: add exact overlapping chunk calculations"`.** + +### Task 2: Rich FFprobe source metadata + +**Files:** +- Modify: `src/voice_transcriptor/models.py` +- Modify: `src/voice_transcriptor/services/media_probe.py` +- Modify: `tests/test_media_probe.py` + +**Interfaces:** +- Extends: `MediaInfo(..., duration_text: str | None, has_audio: bool, has_video: bool)` while retaining existing fields. +- `parse_probe_output` selects the first audio codec and preserves the raw positive duration string for exact chunk math. + +- [ ] **Step 1: Add failing tests for audio-only, video-with-audio, and video-without-audio FFprobe JSON.** Use complete payloads with `format.duration` and stream `codec_type`/`codec_name`; assert exact `duration_text`, `has_audio`, and `has_video` literals. +- [ ] **Step 2: Run `python -m pytest tests/test_media_probe.py -v` and confirm missing-field/constructor failures.** +- [ ] **Step 3: Extend `MediaInfo`, parser, and FFprobe `-show_entries` without changing shell/timeout/error safeguards.** +- [ ] **Step 4: Run media-probe tests and the full suite.** +- [ ] **Step 5: Commit with `git commit -m "feat: expose audio and video probe metadata"`.** + +### Task 3: Preprocessing manifests and FFmpeg command contract + +**Files:** +- Modify: `src/voice_transcriptor/models.py` +- Create: `src/voice_transcriptor/services/preprocessing.py` +- Create: `tests/test_preprocessing.py` + +**Interfaces:** +- Produces: `PreprocessingOptions(chunk_duration_seconds=900, overlap_seconds=15, retain_temporary_files=False)`. +- Produces: `CancellationToken.cancel()`, `.cancelled`, and `.raise_if_cancelled()`. +- Produces: `PreprocessingProgress(percent: int, phase: str, message: str)`. +- Produces: `PreprocessingResult(job_directory: Path, manifest_path: Path, retained: bool)` with `.cleanup()` and context-manager behavior. +- Produces: `PreprocessingService(ffmpeg_path, probe_service, temporary_root=None, process_factory=subprocess.Popen).preprocess(source, options, token=None, progress=None) -> PreprocessingResult`. + +- [ ] **Step 1: Write failing tests for extension/settings/source validation.** Assert all seven extensions are accepted case-insensitively and unsupported extension, absent audio, absent/non-positive duration, and invalid overlap fail before process creation. +- [ ] **Step 2: Write a failing command-contract test with a controlled process fake.** Assert list arguments include `-map 0:a:0`, `-vn`, `-sn`, `-dn`, exact `-ss`/`-t`, `-ac 1`, `-ar 24000`, `-c:a aac`, `-b:a 64k`, `-progress pipe:1`, and a job-owned `.m4a` path; assert one process per boundary. +- [ ] **Step 3: Write failing manifest tests.** Read real JSON written under `tmp_path`; assert exact string timestamps, relative chunk paths, source/output metadata, completed count, and atomic terminal `completed` state. +- [ ] **Step 4: Run `python -m pytest tests/test_preprocessing.py -v` and confirm import/interface failure.** +- [ ] **Step 5: Implement validation, job creation, manifest serialization, atomic writes, command construction, and sequential process execution.** Use `mkdtemp`, `Popen` without a shell, text line buffering, `CREATE_NO_WINDOW` when available, and sanitized typed exceptions. +- [ ] **Step 6: Run focused and full tests; refactor manifest helpers only while green.** +- [ ] **Step 7: Commit with `git commit -m "feat: add streaming FFmpeg preprocessing"`.** + +### Task 4: Progress, cancellation, and safe cleanup + +**Files:** +- Modify: `src/voice_transcriptor/services/preprocessing.py` +- Modify: `tests/test_preprocessing.py` + +**Interfaces:** +- Progress callback receives monotonic `PreprocessingProgress` values. +- Cancellation is reported with `PreprocessingCancelled`, distinct from `PreprocessingError`. +- Cleanup only removes the resolved directory identity stored at creation. + +- [ ] **Step 1: Add failing progress tests.** Feed fake `out_time_us=450000000`, `progress=continue`, and `progress=end` lines; assert percentages are monotonic, current-chunk time is weighted against total planned chunk duration, and 100 occurs only after completion. +- [ ] **Step 2: Add failing cancellation tests.** Cancel while the fake process emits progress; assert `terminate()` is called, later process factories are untouched, partial output is removed, and manifest state is `cancelled`. +- [ ] **Step 3: Add failing lifecycle tests.** Assert default cleanup removes the exact job directory, retained mode preserves it, context-manager exit cleans it, and a tampered cleanup target raises rather than deleting another path. +- [ ] **Step 4: Run the focused tests and confirm expected failures.** +- [ ] **Step 5: Implement line-by-line progress parsing, cooperative termination/kill fallback, terminal manifest transitions, and identity-checked cleanup.** Never call `communicate()` in a way that buffers an unbounded stream. +- [ ] **Step 6: Run focused and full tests.** +- [ ] **Step 7: Commit with `git commit -m "feat: add preprocessing cancellation and cleanup"`.** + +### Task 5: Persist advanced preprocessing settings + +**Files:** +- Modify: `src/voice_transcriptor/models.py` +- Modify: `src/voice_transcriptor/services/settings.py` +- Modify: `tests/test_settings.py` + +**Interfaces:** +- Extends: `AppSettings(model, language, output_directory, chunk_duration_seconds=900, chunk_overlap_seconds=15, retain_temporary_files=False)`. +- Existing three-argument construction remains valid through defaults. + +- [ ] **Step 1: Add failing tests for defaults, new-value round trip, legacy three-key JSON, invalid overlap fallback, and continued absence of API-key fields.** Expected JSON literals include the three new snake-case keys. +- [ ] **Step 2: Run `python -m pytest tests/test_settings.py -v` and confirm constructor/serialization failures.** +- [ ] **Step 3: Implement backward-compatible loading and strict type/range validation.** Reject booleans where integer seconds are expected and require `0 <= overlap < duration`. +- [ ] **Step 4: Run settings and full tests.** +- [ ] **Step 5: Commit with `git commit -m "feat: persist preprocessing settings"`.** + +### Task 6: Advanced settings UI + +**Files:** +- Modify: `src/voice_transcriptor/ui/settings_dialog.py` +- Modify: `tests/ui/test_settings_dialog.py` + +**Interfaces:** +- Adds object-named widgets `advancedToggle`, `advancedPanel`, `chunkDurationInput`, `chunkOverlapInput`, and `retainTemporaryFilesInput`. +- `settings_saved` emits the complete updated `AppSettings`. + +- [ ] **Step 1: Add failing Qt tests for initially hidden advanced panel, toggle visibility, populated defaults, complete save, and overlap-equals-duration warning without persistence.** +- [ ] **Step 2: Run the focused UI test and confirm widgets are missing.** +- [ ] **Step 3: Implement a checkable Advanced button, panel, two `QSpinBox` controls, and `QCheckBox`; preserve existing API-key handling and directory validation.** +- [ ] **Step 4: Run focused UI and full tests.** +- [ ] **Step 5: Commit with `git commit -m "feat: add advanced chunk settings"`.** + +### Task 7: Main window and asynchronous preprocessing worker + +**Files:** +- Create: `src/voice_transcriptor/ui/main_window.py` +- Create: `tests/ui/test_main_window.py` + +**Interfaces:** +- Produces: `MainWindow(media_probe, preprocessing_service, settings_repository, credentials)`. +- Produces internal `PreprocessingWorker(QRunnable)` and signal object carrying progress/result/error/cancelled. +- Provides `select_file(path: Path)`, `start_preprocessing()`, and `cancel_preprocessing()`. + +- [ ] **Step 1: Write failing Qt tests for controls and supported file selection.** Assert Browse, Settings, Prepare, progress bar, Cancel, metadata, and log exist; unsupported/multi-file inputs are rejected. +- [ ] **Step 2: Write failing worker-state tests with a blocking fake service.** Start work, assert selection/settings/prepare disabled and Cancel enabled; emit progress and assert displayed value/message; cancel and assert the same token is cancelled; finish each terminal signal and assert controls restore. +- [ ] **Step 3: Write failing stale-probe and close-during-work tests.** Ensure an older selection cannot overwrite newer metadata and close requests cancellation. +- [ ] **Step 4: Run `python -m pytest tests/ui/test_main_window.py -v` and confirm import failure.** +- [ ] **Step 5: Build the minimal native layout and async QRunnable orchestration.** Keep service calls off the GUI thread, connect queued signals, retain worker/signal references through completion, and clean results according to retention policy. +- [ ] **Step 6: Run focused UI and full tests.** +- [ ] **Step 7: Commit with `git commit -m "feat: add preprocessing desktop workflow"`.** + +### Task 8: Application wiring and end-to-end verification + +**Files:** +- Create: `src/voice_transcriptor/app.py` +- Create: `src/voice_transcriptor/__main__.py` +- Create: `tests/ui/test_app.py` +- Modify: `README.md` + +**Interfaces:** +- Produces: `create_main_window() -> MainWindow` and `main() -> int`. +- Preserves console entry point `voice-transcriptor = voice_transcriptor.app:main`. + +- [ ] **Step 1: Write a failing offscreen smoke test that creates, shows, processes events for, and closes the concrete window.** +- [ ] **Step 2: Run `python -m pytest tests/ui/test_app.py -v` and confirm bootstrap import failure.** +- [ ] **Step 3: Wire detected FFmpeg/FFprobe paths, probe/preprocessing/settings/credential services, guarded startup messages, and `python -m voice_transcriptor`.** Missing tools must leave the window usable with preprocessing disabled. +- [ ] **Step 4: Document supported formats, defaults, duration-based chunking, FFmpeg requirement, temporary retention, cancellation, and the absence of transcription API calls.** +- [ ] **Step 5: Run `python -m pytest -v`, `python -m compileall -q src tests`, and `git diff --check`.** +- [ ] **Step 6: Run `ffmpeg -version` and `ffprobe -version`. If present, generate a short sine/speech-like fixture under a job-owned temporary directory and run the real service, then inspect its manifest and clean the fixture; report absence separately.** +- [ ] **Step 7: Review every specification requirement against code/tests and scan tracked text for credential-like values.** +- [ ] **Step 8: Commit with `git commit -m "docs: document preprocessing workflow"`.** diff --git a/src/voice_transcriptor/__main__.py b/src/voice_transcriptor/__main__.py new file mode 100644 index 0000000..57db62a --- /dev/null +++ b/src/voice_transcriptor/__main__.py @@ -0,0 +1,3 @@ +from voice_transcriptor.app import main + +raise SystemExit(main()) diff --git a/src/voice_transcriptor/app.py b/src/voice_transcriptor/app.py new file mode 100644 index 0000000..9858998 --- /dev/null +++ b/src/voice_transcriptor/app.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sys + +from PySide6.QtWidgets import QApplication + +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.settings import SettingsRepository +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()) + + +class _UnavailableProbe: + def probe(self, path): raise RuntimeError("FFprobe is required to inspect media.") + + +class _UnavailablePreprocessing: + def preprocess(self, *args, **kwargs): raise RuntimeError("FFmpeg is required to preprocess media.") + + +def main() -> int: + application = QApplication.instance() or QApplication(sys.argv) + window = create_main_window(); window.show(); return application.exec() diff --git a/src/voice_transcriptor/models.py b/src/voice_transcriptor/models.py index 6669607..5203bf0 100644 --- a/src/voice_transcriptor/models.py +++ b/src/voice_transcriptor/models.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from decimal import Decimal from pathlib import Path @@ -7,6 +8,9 @@ class AppSettings: model: str language: str output_directory: Path + chunk_duration_seconds: int = 900 + chunk_overlap_seconds: int = 15 + retain_temporary_files: bool = False @dataclass(frozen=True, slots=True) @@ -25,4 +29,17 @@ class MediaInfo: size_bytes: int duration_seconds: float | None audio_codec: str | None + duration_text: str | None = None + has_audio: bool = False + has_video: bool = False + +@dataclass(frozen=True, slots=True) +class ChunkBoundary: + index: int + start_seconds: Decimal + end_seconds: Decimal + + @property + def duration_seconds(self) -> Decimal: + return self.end_seconds - self.start_seconds diff --git a/src/voice_transcriptor/services/chunking.py b/src/voice_transcriptor/services/chunking.py new file mode 100644 index 0000000..01f3e5d --- /dev/null +++ b/src/voice_transcriptor/services/chunking.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from typing import Iterable + +from voice_transcriptor.models import ChunkBoundary + + +def _decimal(value: Decimal | str | int | float) -> Decimal: + try: + result = Decimal(str(value)) + except (InvalidOperation, ValueError) as exc: + raise ValueError("Time values must be finite decimal numbers.") from exc + if not result.is_finite(): + raise ValueError("Time values must be finite decimal numbers.") + return result + + +def calculate_chunk_boundaries( + total_duration: Decimal | str | int | float, + chunk_duration: Decimal | str | int | float = Decimal("900"), + overlap: Decimal | str | int | float = Decimal("15"), +) -> tuple[ChunkBoundary, ...]: + total = _decimal(total_duration) + chunk = _decimal(chunk_duration) + shared = _decimal(overlap) + if total <= 0 or chunk <= 0: + raise ValueError("Duration and chunk duration must be greater than zero.") + if shared < 0 or shared >= chunk: + raise ValueError("Overlap must be non-negative and smaller than chunk duration.") + + result: list[ChunkBoundary] = [] + start = Decimal("0") + stride = chunk - shared + while start < total: + end = min(start + chunk, total) + result.append(ChunkBoundary(len(result), start, end)) + if end == total: + break + start += stride + return tuple(result) + + +def total_chunk_duration(boundaries: Iterable[ChunkBoundary]) -> Decimal: + return sum((item.duration_seconds for item in boundaries), Decimal("0")) + + +def source_timestamp( + boundary: ChunkBoundary, local_seconds: Decimal | str | int | float +) -> Decimal: + local = _decimal(local_seconds) + if local < 0 or local > boundary.duration_seconds: + raise ValueError("Local timestamp is outside the chunk.") + return boundary.start_seconds + local diff --git a/src/voice_transcriptor/services/media_probe.py b/src/voice_transcriptor/services/media_probe.py index 95923d8..43f8184 100644 --- a/src/voice_transcriptor/services/media_probe.py +++ b/src/voice_transcriptor/services/media_probe.py @@ -74,7 +74,12 @@ def parse_probe_output(path: Path, size_bytes: int, payload: str) -> MediaInfo: (stream.get("codec_name") for stream in streams if stream.get("codec_type") == "audio"), None, ) - return MediaInfo(path=path, size_bytes=size_bytes, duration_seconds=duration, audio_codec=codec) + duration_text = str(raw_duration) if raw_duration is not None else None + return MediaInfo( + path=path, size_bytes=size_bytes, duration_seconds=duration, audio_codec=codec, + duration_text=duration_text, has_audio=codec is not None, + has_video=any(stream.get("codec_type") == "video" for stream in streams), + ) class MediaProbeService: diff --git a/src/voice_transcriptor/services/preprocessing.py b/src/voice_transcriptor/services/preprocessing.py new file mode 100644 index 0000000..3dc0da2 --- /dev/null +++ b/src/voice_transcriptor/services/preprocessing.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +import threading +import uuid +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Callable + +from voice_transcriptor.services.chunking import calculate_chunk_boundaries, total_chunk_duration + +SUPPORTED_EXTENSIONS = frozenset({".m4a", ".mp3", ".wav", ".mp4", ".mov", ".webm", ".mkv"}) + + +class PreprocessingError(RuntimeError): pass +class PreprocessingCancelled(PreprocessingError): pass + + +@dataclass(frozen=True, slots=True) +class PreprocessingOptions: + chunk_duration_seconds: int = 900 + overlap_seconds: int = 15 + retain_temporary_files: bool = False + + +@dataclass(frozen=True, slots=True) +class PreprocessingProgress: + percent: int + phase: str + message: str + + +class CancellationToken: + def __init__(self) -> None: self._event = threading.Event() + def cancel(self) -> None: self._event.set() + @property + def cancelled(self) -> bool: return self._event.is_set() + def raise_if_cancelled(self) -> None: + if self.cancelled: raise PreprocessingCancelled("Preprocessing was cancelled.") + + +@dataclass(slots=True) +class PreprocessingResult: + job_directory: Path + manifest_path: Path + retained: bool + _identity: Path + + def cleanup(self) -> None: + if self.retained or not self.job_directory.exists(): return + if self.job_directory.resolve() != self._identity: + raise PreprocessingError("Refusing to clean an unexpected temporary directory.") + shutil.rmtree(self.job_directory) + + def __enter__(self): return self + def __exit__(self, *args): self.cleanup() + + +class PreprocessingService: + def __init__(self, ffmpeg_path: Path, probe_service, temporary_root: Path | None = None, process_factory=subprocess.Popen) -> None: + self.ffmpeg_path = ffmpeg_path + self.probe_service = probe_service + self.temporary_root = temporary_root + self.process_factory = process_factory + + def preprocess(self, source: Path, options: PreprocessingOptions, token: CancellationToken | None = None, progress: Callable[[PreprocessingProgress], None] | None = None) -> PreprocessingResult: + token = token or CancellationToken() + source = source.resolve() + if source.suffix.lower() not in SUPPORTED_EXTENSIONS: raise PreprocessingError("Unsupported media format.") + token.raise_if_cancelled() + info = self.probe_service.probe(source) + if not info.has_audio: raise PreprocessingError("The selected media has no audio stream.") + raw_duration = info.duration_text if info.duration_text is not None else info.duration_seconds + try: duration = Decimal(str(raw_duration)) + except (InvalidOperation, ValueError): raise PreprocessingError("The media duration is unavailable.") + boundaries = calculate_chunk_boundaries(duration, options.chunk_duration_seconds, options.overlap_seconds) + root = self.temporary_root + if root is not None: root.mkdir(parents=True, exist_ok=True) + job = Path(tempfile.mkdtemp(prefix=f"voice-transcriptor-{uuid.uuid4().hex[:8]}-", dir=root)).resolve() + chunks_dir = job / "chunks"; chunks_dir.mkdir() + manifest_path = job / "manifest.json" + chunk_items = [{"index": b.index, "path": f"chunks/chunk-{b.index:05d}.m4a", "source_start_seconds": str(b.start_seconds), "source_end_seconds": str(b.end_seconds), "duration_seconds": str(b.duration_seconds)} for b in boundaries] + manifest = {"schema_version": 1, "job_id": job.name, "state": "running", "source": {"path": str(source), "size_bytes": info.size_bytes, "duration_seconds": str(duration), "audio_codec": info.audio_codec, "has_video": info.has_video}, "settings": {"chunk_duration_seconds": options.chunk_duration_seconds, "overlap_seconds": options.overlap_seconds}, "output": {"codec": "aac", "bitrate": "64k", "sample_rate": 24000, "channels": 1, "container": "m4a"}, "chunks": chunk_items, "completed_chunks": 0} + self._write_manifest(manifest_path, manifest) + total_work = total_chunk_duration(boundaries) + completed = Decimal("0"); last_percent = 0 + try: + for boundary, item in zip(boundaries, chunk_items): + token.raise_if_cancelled() + output = job / item["path"] + command = self._command(source, output, boundary.start_seconds, boundary.duration_seconds) + flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + process = self.process_factory(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, creationflags=flags) + assert process.stdout is not None + for line in process.stdout: + if token.cancelled: + process.terminate() + try: process.wait(timeout=3) + except subprocess.TimeoutExpired: process.kill() + raise PreprocessingCancelled("Preprocessing was cancelled.") + if line.startswith("out_time_us="): + try: current = Decimal(line.partition("=")[2].strip()) / Decimal("1000000") + except InvalidOperation: continue + percent = min(99, int((completed + min(current, boundary.duration_seconds)) * 100 / total_work)) + if percent >= last_percent: + last_percent = percent + if progress: progress(PreprocessingProgress(percent, "encoding", f"Preparing chunk {boundary.index + 1} of {len(boundaries)}")) + if process.wait() != 0: raise PreprocessingError("FFmpeg could not preprocess the media.") + completed += boundary.duration_seconds + manifest["completed_chunks"] = boundary.index + 1 + self._write_manifest(manifest_path, manifest) + manifest["state"] = "completed"; self._write_manifest(manifest_path, manifest) + if progress: progress(PreprocessingProgress(100, "completed", "Preprocessing complete.")) + return PreprocessingResult(job, manifest_path, options.retain_temporary_files, job) + except PreprocessingCancelled: + manifest["state"] = "cancelled"; self._write_manifest(manifest_path, manifest) + if not options.retain_temporary_files: shutil.rmtree(job, ignore_errors=True) + raise + except Exception as exc: + manifest["state"] = "failed"; manifest["error"] = str(exc); self._write_manifest(manifest_path, manifest) + if not options.retain_temporary_files: shutil.rmtree(job, ignore_errors=True) + if isinstance(exc, PreprocessingError): raise + raise PreprocessingError("Preprocessing failed.") from exc + + def _command(self, source: Path, output: Path, start: Decimal, duration: Decimal) -> list[str]: + return [str(self.ffmpeg_path), "-hide_banner", "-y", "-ss", str(start), "-i", str(source), "-t", str(duration), "-map", "0:a:0", "-vn", "-sn", "-dn", "-ac", "1", "-ar", "24000", "-c:a", "aac", "-b:a", "64k", "-progress", "pipe:1", "-nostats", str(output)] + + @staticmethod + def _write_manifest(path: Path, manifest: dict) -> None: + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + temporary.replace(path) diff --git a/src/voice_transcriptor/services/settings.py b/src/voice_transcriptor/services/settings.py index b8dff72..3f46f98 100644 --- a/src/voice_transcriptor/services/settings.py +++ b/src/voice_transcriptor/services/settings.py @@ -51,6 +51,9 @@ class SettingsRepository: "model": settings.model, "language": settings.language, "output_directory": str(settings.output_directory), + "chunk_duration_seconds": settings.chunk_duration_seconds, + "chunk_overlap_seconds": settings.chunk_overlap_seconds, + "retain_temporary_files": settings.retain_temporary_files, } temporary_path: Path | None = None try: @@ -87,4 +90,14 @@ class SettingsRepository: or not output_directory.strip() ): raise ValueError("Settings payload is invalid.") - return AppSettings(model, language, Path(output_directory)) + chunk_duration = payload.get("chunk_duration_seconds", 900) + overlap = payload.get("chunk_overlap_seconds", 15) + retain = payload.get("retain_temporary_files", False) + if ( + isinstance(chunk_duration, bool) or not isinstance(chunk_duration, int) + or isinstance(overlap, bool) or not isinstance(overlap, int) + or not isinstance(retain, bool) or chunk_duration <= 0 + or overlap < 0 or overlap >= chunk_duration + ): + raise ValueError("Preprocessing settings are invalid.") + return AppSettings(model, language, Path(output_directory), chunk_duration, overlap, retain) diff --git a/src/voice_transcriptor/ui/main_window.py b/src/voice_transcriptor/ui/main_window.py new file mode 100644 index 0000000..2364ac6 --- /dev/null +++ b/src/voice_transcriptor/ui/main_window.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal, Slot +from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QMainWindow, QPlainTextEdit, QProgressBar, QPushButton, QVBoxLayout, QWidget + +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.ui.settings_dialog import SettingsDialog + + +class WorkerSignals(QObject): + progress = Signal(object) + completed = Signal(object) + cancelled = Signal() + failed = Signal(str) + + +class PreprocessingWorker(QRunnable): + def __init__(self, service, source: Path, options: PreprocessingOptions, token: CancellationToken) -> None: + super().__init__(); self.service = service; self.source = source; self.options = options; self.token = token; self.signals = WorkerSignals() + + @Slot() + def run(self) -> None: + try: + result = self.service.preprocess(self.source, self.options, 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(result) + + +class MainWindow(QMainWindow): + def __init__(self, media_probe, preprocessing_service, settings_repository, credentials, 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.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) + central = QWidget(self); layout = QVBoxLayout(central) + self.file_label = QLabel("No media selected", central); layout.addWidget(self.file_label) + 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) + 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) + + def browse(self) -> None: + filters = "Media files (*.m4a *.mp3 *.wav *.mp4 *.mov *.webm *.mkv)" + filename, _ = QFileDialog.getOpenFileName(self, "Choose recording", "", filters) + if filename: self.select_file(Path(filename)) + + def select_file(self, path: Path) -> None: + if path.suffix.lower() not in SUPPORTED_EXTENSIONS: + self.log.appendPlainText("Unsupported media format."); return + try: media = self.media_probe.probe(path) + except Exception as exc: + self.log.appendPlainText(str(exc)); self.prepare_button.setEnabled(False); return + if not media.has_audio: + self.log.appendPlainText("The selected media has no audio stream."); return + self.selected_media = media + self.file_label.setText(media.path.name) + self.metadata_label.setText(f"{format_file_size(media.size_bytes)} · {format_duration(media.duration_seconds)} · {media.audio_codec or 'Unknown codec'}") + self.prepare_button.setEnabled(True) + + def start_preprocessing(self) -> None: + 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) + 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) + + def _begin_busy_state(self) -> None: + self._cancellation_token = CancellationToken(); self.prepare_button.setEnabled(False); self.browse_button.setEnabled(False); self.settings_button.setEnabled(False); self.cancel_button.setEnabled(True); self.progress_bar.setValue(0) + + def cancel_preprocessing(self) -> None: + 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) + + @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}") + else: result.cleanup(); self.log.appendPlainText("Audio preprocessing complete.") + self._finish_busy_state() + + @Slot() + def _on_cancelled(self) -> None: self.log.appendPlainText("Preprocessing cancelled."); self._finish_busy_state() + @Slot(str) + def _on_failed(self, message: str) -> None: self.log.appendPlainText(message); self._finish_busy_state() + def _finish_busy_state(self) -> None: + self.browse_button.setEnabled(True); self.settings_button.setEnabled(True); self.prepare_button.setEnabled(self.selected_media is not None); self.cancel_button.setEnabled(False); self._worker = None + + def open_settings(self) -> None: + dialog = SettingsDialog(self.settings, self.settings_repository, self.credentials, self) + dialog.settings_saved.connect(self._set_settings); dialog.exec() + + @Slot(object) + def _set_settings(self, settings) -> None: self.settings = settings + + def dragEnterEvent(self, event) -> None: + urls = event.mimeData().urls() + if len(urls) == 1 and urls[0].isLocalFile(): event.acceptProposedAction() + + def dropEvent(self, event) -> None: + urls = event.mimeData().urls() + if len(urls) == 1: self.select_file(Path(urls[0].toLocalFile())) + + def closeEvent(self, event) -> None: + if self.cancel_button.isEnabled(): self.cancel_preprocessing() + super().closeEvent(event) diff --git a/src/voice_transcriptor/ui/settings_dialog.py b/src/voice_transcriptor/ui/settings_dialog.py index 113e7f1..729349c 100644 --- a/src/voice_transcriptor/ui/settings_dialog.py +++ b/src/voice_transcriptor/ui/settings_dialog.py @@ -9,13 +9,17 @@ from PySide6.QtCore import Signal from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, + QCheckBox, QFileDialog, QFormLayout, QHBoxLayout, QLineEdit, QMessageBox, QPushButton, + QSpinBox, + QToolButton, QVBoxLayout, + QWidget, ) from voice_transcriptor.models import AppSettings @@ -63,6 +67,32 @@ class SettingsDialog(QDialog): form.addRow("Language", self.language_input) form.addRow("Output directory", output_directory_layout) + self.advanced_toggle = QToolButton(self) + self.advanced_toggle.setText("Advanced") + self.advanced_toggle.setCheckable(True) + self.advanced_toggle.setObjectName("advancedToggle") + self.advanced_panel = QWidget(self) + self.advanced_panel.setObjectName("advancedPanel") + advanced_form = QFormLayout(self.advanced_panel) + self.chunk_duration_input = QSpinBox(self.advanced_panel) + self.chunk_duration_input.setObjectName("chunkDurationInput") + self.chunk_duration_input.setRange(1, 86400) + self.chunk_duration_input.setSuffix(" seconds") + self.chunk_duration_input.setValue(settings.chunk_duration_seconds) + self.chunk_overlap_input = QSpinBox(self.advanced_panel) + self.chunk_overlap_input.setObjectName("chunkOverlapInput") + self.chunk_overlap_input.setRange(0, 3600) + self.chunk_overlap_input.setSuffix(" seconds") + self.chunk_overlap_input.setValue(settings.chunk_overlap_seconds) + self.retain_temporary_files_input = QCheckBox("Retain temporary files for debugging", self.advanced_panel) + self.retain_temporary_files_input.setObjectName("retainTemporaryFilesInput") + self.retain_temporary_files_input.setChecked(settings.retain_temporary_files) + advanced_form.addRow("Chunk duration", self.chunk_duration_input) + advanced_form.addRow("Overlap", self.chunk_overlap_input) + advanced_form.addRow(self.retain_temporary_files_input) + self.advanced_panel.hide() + self.advanced_toggle.toggled.connect(self.advanced_panel.setVisible) + self.button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel, parent=self, @@ -76,6 +106,8 @@ class SettingsDialog(QDialog): layout = QVBoxLayout(self) layout.addLayout(form) + layout.addWidget(self.advanced_toggle) + layout.addWidget(self.advanced_panel) layout.addWidget(self.button_box) def choose_output_directory(self) -> None: @@ -107,7 +139,15 @@ class SettingsDialog(QDialog): QMessageBox.warning(self, "Invalid settings", "Model and language are required.") return - updated_settings = AppSettings(model, language, output_directory) + chunk_duration = self.chunk_duration_input.value() + overlap = self.chunk_overlap_input.value() + if overlap >= chunk_duration: + QMessageBox.warning(self, "Invalid settings", "Overlap must be smaller than chunk duration.") + return + updated_settings = AppSettings( + model, language, output_directory, chunk_duration, overlap, + self.retain_temporary_files_input.isChecked(), + ) try: self._repository.save(updated_settings) except SettingsError: diff --git a/tests/test_chunking.py b/tests/test_chunking.py new file mode 100644 index 0000000..8450838 --- /dev/null +++ b/tests/test_chunking.py @@ -0,0 +1,50 @@ +from decimal import Decimal + +import pytest + +from voice_transcriptor.services.chunking import ( + calculate_chunk_boundaries, + source_timestamp, + total_chunk_duration, +) + + +@pytest.mark.parametrize( + ("total", "chunk", "overlap", "expected"), + [ + ("600", "900", "15", [("0", "600")]), + ("900", "900", "15", [("0", "900")]), + ("1800", "900", "15", [("0", "900"), ("885", "1785"), ("1770", "1800")]), + ("901.25", "900", "15", [("0", "900"), ("885", "901.25")]), + ("1800", "900", "0", [("0", "900"), ("900", "1800")]), + ], +) +def test_chunk_boundaries_keep_context_and_exact_final_end( + total: str, chunk: str, overlap: str, expected: list[tuple[str, str]] +) -> None: + boundaries = calculate_chunk_boundaries(total, chunk, overlap) + assert [(str(item.start_seconds), str(item.end_seconds)) for item in boundaries] == expected + + +def test_overlap_is_included_in_total_generated_audio_duration() -> None: + boundaries = calculate_chunk_boundaries("1800", "900", "15") + assert total_chunk_duration(boundaries) == Decimal("1830") + + +def test_local_timestamp_maps_to_exact_source_offset() -> None: + boundary = calculate_chunk_boundaries("1800", "900", "15")[1] + assert source_timestamp(boundary, "12.5") == Decimal("897.5") + + +def test_many_hour_recording_ends_exactly_at_source_duration() -> None: + boundaries = calculate_chunk_boundaries("28800.125", "900", "15") + assert boundaries[-1].end_seconds == Decimal("28800.125") + + +@pytest.mark.parametrize( + ("total", "chunk", "overlap"), + [("0", "900", "15"), ("10", "0", "0"), ("10", "10", "10"), ("10", "10", "-1")], +) +def test_invalid_chunk_configuration_is_rejected(total: str, chunk: str, overlap: str) -> None: + with pytest.raises(ValueError): + calculate_chunk_boundaries(total, chunk, overlap) diff --git a/tests/test_media_probe.py b/tests/test_media_probe.py index 8eead66..d7aebe9 100644 --- a/tests/test_media_probe.py +++ b/tests/test_media_probe.py @@ -43,6 +43,9 @@ def test_parses_audio_only_media(tmp_path: Path) -> None: assert info.duration_seconds == 4.5 assert info.audio_codec == "aac" + assert info.duration_text == "4.5" + assert info.has_audio is True + assert info.has_video is False def test_parses_video_with_audio(tmp_path: Path) -> None: @@ -55,6 +58,7 @@ def test_parses_video_with_audio(tmp_path: Path) -> None: info = parse_probe_output(media, 12, payload) assert info.duration_seconds == 3661.25 assert info.audio_codec == "aac" + assert info.has_video is True def test_missing_metadata_is_unknown(tmp_path: Path) -> None: diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py new file mode 100644 index 0000000..2309a38 --- /dev/null +++ b/tests/test_preprocessing.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path + +import pytest + +from voice_transcriptor.models import MediaInfo +from voice_transcriptor.services.preprocessing import CancellationToken, PreprocessingCancelled, PreprocessingOptions, PreprocessingService + + +class FakeProbe: + def __init__(self, media: MediaInfo): self.media = media + def probe(self, path: Path) -> MediaInfo: return self.media + + +class FakeProcess: + def __init__(self, command): + self.stdout = iter(["out_time_us=450000000\n", "progress=continue\n", "progress=end\n"]) + self.returncode = 0 + self.terminated = False + def wait(self, timeout=None): return self.returncode + def poll(self): return self.returncode + def terminate(self): self.terminated = True + def kill(self): self.terminated = True + + +def make_media(path: Path, duration="1800") -> MediaInfo: + path.write_bytes(b"source") + return MediaInfo(path, 6, float(duration), "aac", duration, True, path.suffix in {".mp4", ".mov", ".mkv", ".webm"}) + + +def test_preprocess_builds_streaming_commands_and_exact_manifest(tmp_path: Path) -> None: + source = tmp_path / "recording.mp4" + commands = [] + def factory(command, **kwargs): + commands.append(command); Path(command[-1]).write_bytes(b"chunk"); return FakeProcess(command) + progress = [] + service = PreprocessingService(Path("ffmpeg"), FakeProbe(make_media(source)), tmp_path / "jobs", factory) + result = service.preprocess(source, PreprocessingOptions(), progress=progress.append) + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert len(commands) == 3 + assert [commands[0][commands[0].index(f) + 1] for f in ("-ss", "-t", "-ac", "-ar", "-c:a", "-b:a")] == ["0", "900", "1", "24000", "aac", "64k"] + assert all("0:a:0" in command and "-progress" in command for command in commands) + assert manifest["state"] == "completed" + assert manifest["chunks"][1]["source_start_seconds"] == "885" + assert manifest["chunks"][-1]["source_end_seconds"] == "1800" + assert manifest["completed_chunks"] == 3 + assert progress[-1].percent == 100 + assert [p.percent for p in progress] == sorted(p.percent for p in progress) + + +def test_cleanup_and_retention_are_job_scoped(tmp_path: Path) -> None: + source = tmp_path / "recording.wav" + def factory(command, **kwargs): Path(command[-1]).write_bytes(b"x"); return FakeProcess(command) + service = PreprocessingService(Path("ffmpeg"), FakeProbe(make_media(source, "10")), tmp_path / "jobs", factory) + result = service.preprocess(source, PreprocessingOptions()); job = result.job_directory; result.cleanup(); assert not job.exists() + retained = service.preprocess(source, PreprocessingOptions(retain_temporary_files=True)); retained.cleanup(); assert retained.job_directory.exists() + + +def test_cancelled_job_stops_before_process(tmp_path: Path) -> None: + source = tmp_path / "recording.m4a"; token = CancellationToken(); token.cancel() + service = PreprocessingService(Path("ffmpeg"), FakeProbe(make_media(source, "10")), tmp_path / "jobs", lambda *a, **k: pytest.fail("started")) + with pytest.raises(PreprocessingCancelled): service.preprocess(source, PreprocessingOptions(), token) + + +@pytest.mark.parametrize("suffix", [".m4a", ".mp3", ".wav", ".mp4", ".mov", ".webm", ".mkv"]) +def test_supported_extensions(tmp_path: Path, suffix: str) -> None: + source = tmp_path / f"recording{suffix}" + def factory(command, **kwargs): Path(command[-1]).write_bytes(b"x"); return FakeProcess(command) + result = PreprocessingService(Path("ffmpeg"), FakeProbe(make_media(source, "1")), tmp_path / "jobs", factory).preprocess(source, PreprocessingOptions()) + assert result.manifest_path.exists() diff --git a/tests/test_settings.py b/tests/test_settings.py index 85b57b2..372b98b 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -43,6 +43,9 @@ def test_save_and_load_round_trip_utf8_settings(tmp_path: Path) -> None: "model": "gpt-4o-transcribe", "language": "pt-BR", "output_directory": str(tmp_path / "Transcrições"), + "chunk_duration_seconds": 900, + "chunk_overlap_seconds": 15, + "retain_temporary_files": False, } @@ -107,3 +110,21 @@ def test_saved_settings_never_include_api_key_fields(tmp_path: Path) -> None: serialized = path.read_text(encoding="utf-8").lower() assert "api" not in serialized assert "key" not in serialized + + +def test_preprocessing_settings_round_trip(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + expected = AppSettings("model", "pt-BR", tmp_path, 1200, 20, True) + repository = SettingsRepository(path) + repository.save(expected) + actual, warning = repository.load() + assert actual == expected + assert warning is None + + +def test_legacy_settings_receive_preprocessing_defaults(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text(json.dumps({"model": "model", "language": "pt-BR", "output_directory": str(tmp_path)}), encoding="utf-8") + settings, warning = SettingsRepository(path).load() + assert (settings.chunk_duration_seconds, settings.chunk_overlap_seconds, settings.retain_temporary_files) == (900, 15, False) + assert warning is None diff --git a/tests/ui/test_app.py b/tests/ui/test_app.py new file mode 100644 index 0000000..6971bfc --- /dev/null +++ b/tests/ui/test_app.py @@ -0,0 +1,6 @@ +def test_concrete_window_starts_offscreen(qtbot, monkeypatch) -> None: + from voice_transcriptor import app + window = app.create_main_window() + qtbot.addWidget(window) + window.show() + assert window.windowTitle() == "Voice Transcriptor" diff --git a/tests/ui/test_main_window.py b/tests/ui/test_main_window.py new file mode 100644 index 0000000..24595dd --- /dev/null +++ b/tests/ui/test_main_window.py @@ -0,0 +1,49 @@ +from pathlib import Path + +from voice_transcriptor.models import AppSettings, MediaInfo +from voice_transcriptor.ui.main_window import MainWindow + + +class Repository: + def __init__(self, root: Path): self.settings = AppSettings("model", "pt-BR", root) + def load(self): return self.settings, None + def save(self, settings): self.settings = settings + + +class Credentials: + def set_api_key(self, value): pass + + +class Probe: + def probe(self, path): return MediaInfo(path, 10, 60.0, "aac", "60", True, False) + + +class Preprocessor: + def preprocess(self, *args, **kwargs): raise AssertionError("not started") + + +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.cancel_button.text() == "Cancel" + assert window.cancel_button.isEnabled() is False + assert window.progress_bar.value() == 0 + + +def test_select_file_populates_media_and_enables_preprocessing(qtbot, tmp_path: Path) -> None: + source = tmp_path / "recording.mp3"; source.write_bytes(b"x") + window = MainWindow(Probe(), Preprocessor(), Repository(tmp_path), Credentials()) + qtbot.addWidget(window) + window.select_file(source) + assert window.selected_media.path == source + assert window.prepare_button.isEnabled() is True + assert "recording.mp3" in window.file_label.text() + + +def test_cancel_button_cancels_active_token(qtbot, tmp_path: Path) -> None: + window = MainWindow(Probe(), Preprocessor(), Repository(tmp_path), Credentials()) + qtbot.addWidget(window) + window._begin_busy_state() + window.cancel_preprocessing() + assert window._cancellation_token.cancelled is True diff --git a/tests/ui/test_settings_dialog.py b/tests/ui/test_settings_dialog.py index 28abcd6..ab049f8 100644 --- a/tests/ui/test_settings_dialog.py +++ b/tests/ui/test_settings_dialog.py @@ -50,6 +50,22 @@ def test_populates_fields_from_settings(qtbot, settings: AppSettings) -> None: assert dialog.language_input.text() == settings.language assert dialog.output_directory_input.text() == str(settings.output_directory) assert dialog.api_key_input.text() == "" + assert dialog.advanced_panel.isVisible() is False + assert dialog.chunk_duration_input.value() == 900 + assert dialog.chunk_overlap_input.value() == 15 + + +def test_advanced_settings_are_saved(qtbot, settings: AppSettings) -> None: + repository = FakeRepository() + dialog = SettingsDialog(settings, repository, FakeCredentials()) + qtbot.addWidget(dialog) + dialog.chunk_duration_input.setValue(1200) + dialog.chunk_overlap_input.setValue(20) + dialog.retain_temporary_files_input.setChecked(True) + dialog.save() + assert repository.saved[0].chunk_duration_seconds == 1200 + assert repository.saved[0].chunk_overlap_seconds == 20 + assert repository.saved[0].retain_temporary_files is True def test_api_key_input_uses_password_echo_mode(qtbot, settings: AppSettings) -> None: