18 changed files with 858 additions and 3 deletions
@ -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. |
||||||
@ -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"`.** |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from voice_transcriptor.app import main |
||||||
|
|
||||||
|
raise SystemExit(main()) |
||||||
@ -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() |
||||||
@ -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 |
||||||
@ -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) |
||||||
@ -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) |
||||||
@ -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) |
||||||
@ -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() |
||||||
@ -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" |
||||||
@ -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 |
||||||
Loading…
Reference in new issue