Browse Source

feat: add audio preprocessing and chunking

master
Yutsuo 4 days ago
parent
commit
a1e9e857f3
  1. 29
      README.md
  2. 189
      docs/superpowers/plans/2026-08-30-audio-preprocessing-chunking.md
  3. 3
      src/voice_transcriptor/__main__.py
  4. 31
      src/voice_transcriptor/app.py
  5. 17
      src/voice_transcriptor/models.py
  6. 54
      src/voice_transcriptor/services/chunking.py
  7. 7
      src/voice_transcriptor/services/media_probe.py
  8. 136
      src/voice_transcriptor/services/preprocessing.py
  9. 15
      src/voice_transcriptor/services/settings.py
  10. 122
      src/voice_transcriptor/ui/main_window.py
  11. 42
      src/voice_transcriptor/ui/settings_dialog.py
  12. 50
      tests/test_chunking.py
  13. 4
      tests/test_media_probe.py
  14. 70
      tests/test_preprocessing.py
  15. 21
      tests/test_settings.py
  16. 6
      tests/ui/test_app.py
  17. 49
      tests/ui/test_main_window.py
  18. 16
      tests/ui/test_settings_dialog.py

29
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.

189
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"`.**

3
src/voice_transcriptor/__main__.py

@ -0,0 +1,3 @@
from voice_transcriptor.app import main
raise SystemExit(main())

31
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()

17
src/voice_transcriptor/models.py

@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path from pathlib import Path
@ -7,6 +8,9 @@ class AppSettings:
model: str model: str
language: str language: str
output_directory: Path output_directory: Path
chunk_duration_seconds: int = 900
chunk_overlap_seconds: int = 15
retain_temporary_files: bool = False
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -25,4 +29,17 @@ class MediaInfo:
size_bytes: int size_bytes: int
duration_seconds: float | None duration_seconds: float | None
audio_codec: str | 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

54
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

7
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"), (stream.get("codec_name") for stream in streams if stream.get("codec_type") == "audio"),
None, 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: class MediaProbeService:

136
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)

15
src/voice_transcriptor/services/settings.py

@ -51,6 +51,9 @@ class SettingsRepository:
"model": settings.model, "model": settings.model,
"language": settings.language, "language": settings.language,
"output_directory": str(settings.output_directory), "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 temporary_path: Path | None = None
try: try:
@ -87,4 +90,14 @@ class SettingsRepository:
or not output_directory.strip() or not output_directory.strip()
): ):
raise ValueError("Settings payload is invalid.") 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)

122
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)

42
src/voice_transcriptor/ui/settings_dialog.py

@ -9,13 +9,17 @@ from PySide6.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QCheckBox,
QFileDialog, QFileDialog,
QFormLayout, QFormLayout,
QHBoxLayout, QHBoxLayout,
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QSpinBox,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget,
) )
from voice_transcriptor.models import AppSettings from voice_transcriptor.models import AppSettings
@ -63,6 +67,32 @@ class SettingsDialog(QDialog):
form.addRow("Language", self.language_input) form.addRow("Language", self.language_input)
form.addRow("Output directory", output_directory_layout) 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( self.button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel, QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel,
parent=self, parent=self,
@ -76,6 +106,8 @@ class SettingsDialog(QDialog):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addLayout(form) layout.addLayout(form)
layout.addWidget(self.advanced_toggle)
layout.addWidget(self.advanced_panel)
layout.addWidget(self.button_box) layout.addWidget(self.button_box)
def choose_output_directory(self) -> None: def choose_output_directory(self) -> None:
@ -107,7 +139,15 @@ class SettingsDialog(QDialog):
QMessageBox.warning(self, "Invalid settings", "Model and language are required.") QMessageBox.warning(self, "Invalid settings", "Model and language are required.")
return 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: try:
self._repository.save(updated_settings) self._repository.save(updated_settings)
except SettingsError: except SettingsError:

50
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)

4
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.duration_seconds == 4.5
assert info.audio_codec == "aac" 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: 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) info = parse_probe_output(media, 12, payload)
assert info.duration_seconds == 3661.25 assert info.duration_seconds == 3661.25
assert info.audio_codec == "aac" assert info.audio_codec == "aac"
assert info.has_video is True
def test_missing_metadata_is_unknown(tmp_path: Path) -> None: def test_missing_metadata_is_unknown(tmp_path: Path) -> None:

70
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()

21
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", "model": "gpt-4o-transcribe",
"language": "pt-BR", "language": "pt-BR",
"output_directory": str(tmp_path / "Transcrições"), "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() serialized = path.read_text(encoding="utf-8").lower()
assert "api" not in serialized assert "api" not in serialized
assert "key" 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

6
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"

49
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

16
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.language_input.text() == settings.language
assert dialog.output_directory_input.text() == str(settings.output_directory) assert dialog.output_directory_input.text() == str(settings.output_directory)
assert dialog.api_key_input.text() == "" 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: def test_api_key_input_uses_password_echo_mode(qtbot, settings: AppSettings) -> None:

Loading…
Cancel
Save