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