45 Commits
Author SHA1 Message Date
Ethanfel b52b3e19be feat: improve audio region timeline picking 2026-07-04 20:16:08 +02:00
Ethanfel 678f6e5cc4 feat: add heal cut ffmpeg command 2026-07-04 20:14:19 +02:00
Ethanfel fec08cfcb3 docs: plan audio heal cut implementation 2026-07-04 20:13:21 +02:00
Ethanfel 7ff870ee88 docs: design audio timeline picker and heal cut 2026-07-04 19:42:17 +02:00
Ethanfel 158483ce42 feat: '+ To library' — add the current audio area straight to the clip library 2026-07-04 01:11:05 +02:00
EthanfelandClaude Opus 4.8 0e82a82cde feat: auto-add extracted/edited clips to the library + library Edit opens the editor (v1.7)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:54:43 +02:00
Ethanfel f32d303d26 feat: persistent Library tab in ScanResultsPanel (survives per-file rebuilds) 2026-07-04 00:20:23 +02:00
Ethanfel 5dc7499d7f feat: AudioLibraryTab — persistent extracted-clip library (add/edit/re-export/play/remove) 2026-07-04 00:06:10 +02:00
Ethanfel 7f414d80b8 feat: Audio tab drives timeline audio-mode; drag the band to set the extract region 2026-07-03 02:23:30 +02:00
Ethanfel a993de615c feat: timeline audio-mode — hide clip span, draggable/resizable audio band 2026-07-03 02:10:04 +02:00
EthanfelandClaude Opus 4.8 283bc65588 fix: escape ampersand in 'Extract && Edit' QToolBox title
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:57:43 +02:00
EthanfelandClaude Opus 4.8 d8b890d679 fix: editor cleanup on Escape/reject + stop main audition on open
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:41:41 +02:00
EthanfelandClaude Opus 4.8 4a34a87d37 feat: editor playback + temp cleanup on close + docs (v1.6)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:31:13 +02:00
Ethanfel 4d54f29514 feat: wire editor ops (delete/silence/reverse/trim) + undo/redo + save-as 2026-07-02 18:22:39 +02:00
EthanfelandClaude Opus 4.8 a11e4859dd feat: AudioEditorDialog scaffold + Edit-clip entry point (ops stubbed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:09:54 +02:00
Ethanfel 1553d7faf0 feat: destructive audio op command builders (delete/silence/reverse region) 2026-07-02 17:54:11 +02:00
Ethanfel 68368b4ff6 feat: double-click a merge clip to set its join crossfade/curve override + row labels 2026-07-02 17:50:40 +02:00
EthanfelandClaude Opus 4.8 65200e482e feat: global crossfade curve combo + per-join render/guard plumbing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:40:01 +02:00
Ethanfel a298418156 feat: per-join crossfade durations + acrossfade curve in build_crossfade_merge_command 2026-07-02 17:35:26 +02:00
EthanfelandClaude Opus 4.8 449b3dfa72 feat: waveform playhead during audition + docs (v1.5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:27:37 +02:00
Ethanfel 356cfcc7d7 feat: wire interactive waveform selection to cursor/length (two-way, clamped view) 2026-07-02 17:18:26 +02:00
Ethanfel 25fbf8142f feat: waveform drag-select + wheel zoom (selection_changed/view_changed) 2026-07-02 17:06:46 +02:00
Ethanfel 7ca894d585 feat: waveform view/selection state + band/handle painting 2026-07-02 16:56:36 +02:00
Ethanfel ee7de4d83c feat: pixel<->time geometry helpers for the waveform 2026-07-02 16:54:00 +02:00
EthanfelandClaude Opus 4.8 86980b38cf docs: Phase 3a implementation plan — interactive waveform selection
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:52:29 +02:00
EthanfelandClaude Opus 4.8 799b064703 docs: Phase 3 design — interactive waveform + per-join crossfade/curves + destructive editor
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:51:29 +02:00
Ethanfel 43326730f2 polish: preview failure dialog + clean audition temp on close + drop redundant Qt imports 2026-07-02 16:35:53 +02:00
EthanfelandClaude Opus 4.8 14ec29dbbe feat: merge temp cleanup on close + v1.4 changelog + README (Phase 2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:21:10 +02:00
Ethanfel 3922fc5174 feat: merge save + preview (crossfade render, duration guard, ffplay preview) 2026-07-02 16:18:29 +02:00
Ethanfel b8aa1606d8 feat: Merge sequence — add (file/selection), reorder, remove 2026-07-02 16:04:01 +02:00
EthanfelandClaude Opus 4.8 de7da3cad6 feat: Merge pane (3rd Audio QToolBox page) — list + crossfade + buttons
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:54:05 +02:00
Ethanfel 52417ea1ee feat: build_crossfade_merge_command — chained acrossfade / concat 2026-07-02 15:45:33 +02:00
EthanfelandClaude Opus 4.8 7573277a03 docs: audio merge (crossfade) — Phase 2 design + implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:37:31 +02:00
Ethanfel 578ad87b1a docs: Audio workspace tab (Phase 1) — v1.3 changelog + README; drop orphaned transport spacer 2026-07-02 15:18:38 +02:00
EthanfelandClaude Opus 4.8 c57c078100 feat: play/stop audition of the current audio region
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:13:06 +02:00
Ethanfel 7501c4729b refactor: cap waveform decode length + guard frombuffer + tighten test 2026-07-02 15:00:34 +02:00
Ethanfel 12eaa944a7 feat: read-only waveform strip + manual refresh in the Audio tab 2026-07-02 14:53:32 +02:00
Ethanfel 4c5c4276c1 feat: core/waveform — peak reduction + ffmpeg region decode 2026-07-02 14:51:47 +02:00
Ethanfel 8e03b97742 feat: extract honors format picker + fade/normalize/gain edits 2026-07-02 14:47:49 +02:00
Ethanfel c9a4ac584f feat: Audio tab QToolBox — Extract & Edit + Scan/Classify panes 2026-07-02 14:35:22 +02:00
Ethanfel 4b3b25ffae feat: audio format + fade/normalize/gain widgets; free them from the transport row 2026-07-02 14:26:06 +02:00
EthanfelandClaude Opus 4.8 52ce2d451a refactor: rename Scan deck tab -> Audio (deck wiring only)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:13:48 +02:00
Ethanfel f584e54a8c feat: audio_edit_filters helper + optional -af chain on build_audio_clip_command 2026-07-02 14:09:14 +02:00
EthanfelandClaude Opus 4.8 8f18c7a817 docs: audio tab Phase 1 implementation plan (extract/edit/classify)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:36:37 +02:00
EthanfelandClaude Opus 4.8 e14cebd081 docs: audio workspace tab design (2-phase: extract/edit/classify + dataset)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:33:19 +02:00
15 changed files with 4973 additions and 58 deletions
+1
View File
@@ -3,6 +3,7 @@ __pycache__/
*.pyo *.pyo
.pytest_cache/ .pytest_cache/
.worktrees/ .worktrees/
.superpowers/
.venv/ .venv/
models/ models/
cache/ cache/
+29
View File
@@ -33,6 +33,34 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox - **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox
- **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional) - **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional)
### Audio extraction & editing
- **Extract audio area** — save an exact-length audio slice from the playhead (length steps by 1s, no upper cap)
- **Output format** — WAV / MP3 / FLAC / M4A / OGG / OPUS, remembered between sessions
- **Non-destructive edits** — fade in/out, EBU R128 normalize, and gain, applied via ffmpeg on extract (and audition)
- **Waveform preview** — a read-only strip of the current area (↻ to refresh; decode length-capped so long areas don't stall)
- **Interactive waveform** — drag the in/out handles to set the exact clip region, wheel to zoom, with a playhead during audition
- **Audition** — Play/Stop the current area with the edits applied
### Audio merge (crossfade)
- **Merge pane** — assemble multiple clips into one, crossfading every join (ffmpeg `acrossfade`)
- **Add clips** — the current audio area (with its edits) or audio files from disk; reorder or remove them
- **Crossfade length** — one global crossfade applied at each join (0 = butt-join); clips shorter than the crossfade are flagged before rendering
- **Preview / Save** — audition the merged result, or render and save-as (WAV/MP3/FLAC/…)
### Audio clip editor (destructive)
- **Edit clip** — open the current audio area in an editor; drag to select a region
- **Ops** — Delete / Silence / Reverse / Trim-to-selection, with undo/redo
- **Audition & Save** — play the result, then Save as (WAV/MP3/FLAC/…)
### Clip library
- **Library tab** — a persistent tab in the scan-results column that collects extracted audio clips (auto-added on extract + editor Save)
- **Add / drag-drop** — import existing audio files
- **Per clip** — Edit (opens the clip editor), Re-export (save-as / transcode), Play, Remove
### Audio scanning ### Audio scanning
- **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs - **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs
@@ -43,6 +71,7 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Hard negatives** — mark false positives to refine training - **Hard negatives** — mark false positives to refine training
- **Model versioning** — timestamped backups with rollback support - **Model versioning** — timestamped backups with rollback support
- **Scan export** — batch export from scan results with spread and minimum duration filtering - **Scan export** — batch export from scan results with spread and minimum duration filtering
- **Location** — scan controls live in the **Audio** tab's *Scan / Classify* pane (formerly the *Scan* tab)
### Scan results panel ### Scan results panel
+159 -2
View File
@@ -201,12 +201,37 @@ def probe_duration(path: str) -> float | None:
return None return None
def audio_edit_filters(duration: float, fade_in: float = 0.0,
fade_out: float = 0.0, normalize: bool = False,
gain_db: float = 0.0) -> list[str]:
"""Compose an ffmpeg -af chain for the non-destructive audio edits.
Trim is handled by the caller (it adjusts start/duration), so only
fade / normalize / gain appear here. Returns [] when nothing is set, so
the extract command stays byte-identical to the un-edited case."""
chain: list[str] = []
if fade_in > 0:
chain.append(f"afade=t=in:st=0:d={fade_in}")
if fade_out > 0:
st = round(max(0.0, duration - fade_out), 3)
chain.append(f"afade=t=out:st={st}:d={fade_out}")
if normalize:
# single-pass loudnorm (EBU R128 default target); fine for quick normalize
chain.append("loudnorm")
if gain_db != 0.0:
chain.append(f"volume={gain_db}dB")
return chain
def build_audio_clip_command(input_path: str, start: float, duration: float, def build_audio_clip_command(input_path: str, start: float, duration: float,
out_path: str) -> list[str]: out_path: str,
filters: list[str] | None = None) -> list[str]:
"""ffmpeg command to extract exactly *duration* seconds of audio starting """ffmpeg command to extract exactly *duration* seconds of audio starting
at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…).""" at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…).
*filters* (if any) are joined into a single -af chain."""
ext = os.path.splitext(out_path)[1].lower() ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, []) codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
af = ["-af", ",".join(filters)] if filters else []
return [ return [
_bin("ffmpeg"), "-y", _bin("ffmpeg"), "-y",
"-ss", str(start), "-ss", str(start),
@@ -214,10 +239,142 @@ def build_audio_clip_command(input_path: str, start: float, duration: float,
"-t", str(duration), "-t", str(duration),
"-vn", "-vn",
*codec, *codec,
*af,
out_path, out_path,
] ]
_ACROSSFADE_CURVES = frozenset({
"tri", "qsin", "hsin", "esin", "log", "ipar", "qua", "cub", "squ", "cbr",
"par", "exp", "iqsin", "ihsin", "dese", "desi", "losi", "sinc", "isinc",
"nofade",
})
def build_crossfade_merge_command(clips: list[str], crossfade,
out_path: str, curves=None) -> list[str]:
"""ffmpeg command concatenating *clips* into *out_path*. *crossfade* is a
single duration (applied to every join) or a per-join list of length
len(clips)-1; 0 = butt-join. *curves* is a single acrossfade curve name or a
per-join list (default 'tri', ffmpeg's default, emitted implicitly)."""
if not clips:
raise ValueError("no clips to merge")
n = len(clips)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
cmd = [_bin("ffmpeg"), "-y"]
for c in clips:
cmd += ["-i", c]
if n == 1:
return cmd + ["-vn", *codec, out_path]
# normalize per-join params (n-1 joins)
xfs = [float(crossfade)] * (n - 1) if isinstance(crossfade, (int, float)) \
else [float(x) for x in crossfade]
if curves is None or isinstance(curves, str):
cvs = [curves or "tri"] * (n - 1)
else:
cvs = [str(c) for c in curves]
if len(xfs) != n - 1:
raise ValueError(f"crossfade list must have {n - 1} entries, got {len(xfs)}")
if len(cvs) != n - 1:
raise ValueError(f"curves list must have {n - 1} entries, got {len(cvs)}")
for cv in cvs:
if cv not in _ACROSSFADE_CURVES:
raise ValueError(f"unknown acrossfade curve: {cv!r}")
# Chain per join: [0][1]->[a1]; [a1][2]->[a2]; …; last label = [out].
# A join uses acrossfade when its duration>0, else per-pair concat=n=2.
# The curve suffix is emitted only for non-'tri' curves ('tri' is ffmpeg's
# default, so omitting it keeps the default command strings byte-identical).
parts, prev = [], "0"
for i in range(1, n):
j = i - 1
label = "out" if i == n - 1 else f"a{i}"
xf = xfs[j] if j < len(xfs) else 0.0
cv = cvs[j] if j < len(cvs) else "tri"
if xf > 0:
suffix = f":c1={cv}:c2={cv}" if cv != "tri" else ""
parts.append(f"[{prev}][{i}]acrossfade=d={round(xf, 3)}{suffix}[{label}]")
else:
parts.append(f"[{prev}][{i}]concat=n=2:v=0:a=1[{label}]")
prev = label
fc = ";".join(parts)
return cmd + ["-filter_complex", fc, "-map", "[out]", *codec, out_path]
def build_audio_delete_command(input_path: str, start: float, end: float,
out_path: str) -> list[str]:
"""Remove [start, end] from the audio: keep [0,start] + [end,inf], concat."""
s, e = round(start, 3), round(end, 3)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]concat=n=2:v=0:a=1[out]")
return [_bin("ffmpeg"), "-y", "-i", input_path,
"-filter_complex", fc, "-map", "[out]", *codec, out_path]
def _auto_heal_crossfade(start: float, end: float,
requested: float | None = None) -> float:
if end <= start:
raise ValueError("heal delete end must be greater than start")
if requested is not None:
fade = max(0.0, float(requested))
else:
fade = min(0.25, max(0.04, (end - start) * 0.25))
# Without knowing total duration, clamp only to available pre-roll.
fade = min(fade, max(0.0, float(start)))
return round(fade, 3)
def build_audio_heal_delete_command(input_path: str, start: float, end: float,
out_path: str,
crossfade: float | None = None) -> list[str]:
"""Remove [start, end] and heal the join with a short equal-power crossfade."""
if end <= start:
raise ValueError("heal delete end must be greater than start")
s, e = round(start, 3), round(end, 3)
xf = _auto_heal_crossfade(s, e, crossfade)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
if xf <= 0:
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]concat=n=2:v=0:a=1[out]")
else:
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]acrossfade=d={xf}:c1=qsin:c2=qsin[out]")
return [_bin("ffmpeg"), "-y", "-i", input_path,
"-filter_complex", fc, "-map", "[out]", *codec, out_path]
def build_audio_silence_command(input_path: str, start: float, end: float,
out_path: str) -> list[str]:
"""Silence the [start, end] region (volume=0 gated by an enable expr)."""
s, e = round(start, 3), round(end, 3)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
# commas inside between() must be escaped so the filtergraph parser doesn't
# treat them as filter separators.
af = f"volume=0:enable='between(t\\,{s}\\,{e})'"
return [_bin("ffmpeg"), "-y", "-i", input_path, "-af", af, *codec, out_path]
def build_audio_reverse_command(input_path: str, start: float, end: float,
out_path: str) -> list[str]:
"""Reverse only the [start, end] segment; head and tail unchanged."""
s, e = round(start, 3), round(end, 3)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={s}:end={e},asetpts=PTS-STARTPTS,areverse[b];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[c];"
f"[a][b][c]concat=n=3:v=0:a=1[out]")
return [_bin("ffmpeg"), "-y", "-i", input_path,
"-filter_complex", fc, "-map", "[out]", *codec, out_path]
def detect_hw_encoders() -> list[str]: def detect_hw_encoders() -> list[str]:
"""Probe ffmpeg for available H.264 hardware encoders. """Probe ffmpeg for available H.264 hardware encoders.
+51
View File
@@ -0,0 +1,51 @@
import subprocess
import numpy as np
from core.paths import _bin
def x_to_t(x: float, width: float, view_start: float, view_dur: float) -> float:
"""Map a pixel x in [0,width] to a time in [view_start, view_start+view_dur]."""
if width <= 0 or view_dur <= 0:
return view_start
return view_start + (x / width) * view_dur
def t_to_x(t: float, width: float, view_start: float, view_dur: float) -> int:
"""Map a time to a pixel x in [0,width] (rounded)."""
if width <= 0 or view_dur <= 0:
return 0
return int(round((t - view_start) / view_dur * width))
def peaks(samples, buckets: int = 128) -> list[float]:
"""Reduce a 1-D sample array to *buckets* normalized peak magnitudes (0..1)."""
if samples is None or len(samples) == 0:
return [0.0] * buckets
a = np.abs(np.asarray(samples, dtype="float32"))
idx = np.linspace(0, len(a), buckets + 1).astype(int)
out = [float(a[idx[i]:idx[i + 1]].max()) if idx[i + 1] > idx[i] else 0.0
for i in range(buckets)]
m = max(out) or 1.0
return [v / m for v in out]
def load_region_samples(path: str, start: float, duration: float,
sr: int = 8000) -> np.ndarray:
"""Decode a [start, start+duration] mono slice via ffmpeg for waveform
preview (low sample rate). Returns a float32 numpy array, empty on failure."""
cmd = [
_bin("ffmpeg"), "-ss", str(start), "-i", path, "-t", str(duration),
"-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
"-loglevel", "error", "pipe:1",
]
try:
proc = subprocess.run(cmd, capture_output=True, timeout=60)
except Exception:
return np.zeros(0, dtype="float32")
if proc.returncode != 0 or not proc.stdout:
return np.zeros(0, dtype="float32")
buf = proc.stdout
buf = buf[: len(buf) - (len(buf) % 4)]
return np.frombuffer(buf, dtype="float32")
@@ -0,0 +1,187 @@
# Phase 3a — Interactive waveform selection — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Turn the read-only `AudioWaveform` strip into an interactive selection surface — drag in/out handles + click-drag to select + wheel zoom — whose selection drives the extract region (`_cursor` + `_spn_audio_len`), two-way synced with the length spinbox.
**Architecture:** Pure pixel↔time geometry in `core/waveform.py` (TDD); interactive state + painting + mouse/wheel on `AudioWaveform` (main.py); MainWindow wires the widget's `selection_changed`/`view_changed` to `_cursor`/`_spn_audio_len` and peak re-decode. Extract/audition/add-to-merge are unchanged (they already read `_cursor`+length).
**Design doc:** `docs/plans/2026-07-02-audio-editor-fancier-design.md`
**Branch:** `audio-tab`. UI tests: `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v`. Never combine test_ui_structure.py + test_utils.py (segfault). 3 known pre-existing test_utils.py failures — leave them.
---
## Task W1: Pure pixel↔time geometry (`core/waveform.py`, TDD)
**Files:** Modify `core/waveform.py`; test `tests/test_utils.py`.
**Step 1 — failing tests:**
```python
def test_time_pixel_roundtrip():
from core.waveform import t_to_x, x_to_t
# window [10s, 10s+4s] across 400px
assert x_to_t(0, 400, 10.0, 4.0) == 10.0
assert x_to_t(400, 400, 10.0, 4.0) == 14.0
assert x_to_t(200, 400, 10.0, 4.0) == 12.0
assert t_to_x(12.0, 400, 10.0, 4.0) == 200
# round-trip
for x in (0, 37, 200, 399):
assert abs(t_to_x(x_to_t(x, 400, 10.0, 4.0), 400, 10.0, 4.0) - x) <= 1
def test_time_pixel_guards():
from core.waveform import t_to_x, x_to_t
assert x_to_t(50, 0, 10.0, 4.0) == 10.0 # zero width -> view_start
assert x_to_t(50, 400, 10.0, 0.0) == 10.0 # zero span -> view_start
assert t_to_x(12.0, 400, 10.0, 0.0) == 0 # zero span -> 0
```
**Step 2** — run `...pytest tests/test_utils.py -k "time_pixel" -v` → FAIL.
**Step 3 — implement** in `core/waveform.py`:
```python
def x_to_t(x: float, width: float, view_start: float, view_dur: float) -> float:
"""Map a pixel x in [0,width] to a time in [view_start, view_start+view_dur]."""
if width <= 0 or view_dur <= 0:
return view_start
return view_start + (x / width) * view_dur
def t_to_x(t: float, width: float, view_start: float, view_dur: float) -> int:
"""Map a time to a pixel x in [0,width] (rounded)."""
if width <= 0 or view_dur <= 0:
return 0
return int(round((t - view_start) / view_dur * width))
```
**Step 4**`...pytest tests/test_utils.py -k "time_pixel or peaks or load_region" -v` → PASS.
**Step 5 — commit:** `feat: pixel<->time geometry helpers for the waveform`
---
## Task W2: AudioWaveform gains view + selection state + painting
**Files:** Modify `main.py` (`AudioWaveform`); test `tests/test_ui_structure.py`.
**Step 1 — failing test:**
```python
def test_waveform_view_and_selection(win):
w = win._wave
w.set_view(10.0, 4.0)
w.set_selection(11.0, 12.5)
assert w._view_start == 10.0 and w._view_dur == 4.0
assert w.selection() == (11.0, 12.5)
# selection clamps into the view window
w.set_selection(9.0, 20.0)
s, e = w.selection()
assert s >= 10.0 and e <= 14.0 and s < e
w.set_peaks([0.1, 0.9, 0.3]) # still paints without crashing
```
**Step 2** — run → FAIL.
**Step 3 — implement** on `AudioWaveform` (extend, keep `set_peaks`/`clear`/read-only fallback):
- `__init__`: add `self._view_start = 0.0`, `self._view_dur = 0.0`, `self._sel = None # (start,end) | None`, `self._playhead = None`; `setFixedHeight(96)`; keep tooltip.
- `set_view(start, dur)`: store, `update()`.
- `set_selection(start, end)`: clamp to `[view_start, view_start+view_dur]`, enforce `start<end`; store in `self._sel`; `update()`.
- `selection() -> tuple|None`: return `self._sel`.
- `set_playhead(t|None)`: store, `update()`.
- `paintEvent`: keep peak bars; if `self._sel` and `_view_dur>0`, paint a translucent band (`QColor(0,200,180,50)`) between `t_to_x(sel_start)` and `t_to_x(sel_end)`, and two 2px handle lines (`QColor(0,220,190)`) at those x. If `_playhead` in-window, a 1px vertical `QColor(255,255,255,160)` line. Use `from core.waveform import t_to_x` (function-local import per file style).
**Step 4** — full `tests/test_ui_structure.py` → new test + prior 37 pass.
**Step 5 — commit:** `feat: waveform view/selection state + band/handle painting`
---
## Task W3: Mouse drag + wheel zoom → signals
**Files:** Modify `main.py` (`AudioWaveform`); test `tests/test_ui_structure.py`.
**Step 1 — failing test** (drive the handlers directly with synthetic positions rather than real Qt events where possible):
```python
def test_waveform_drag_emits_selection(win):
from PyQt6.QtCore import QPointF
w = win._wave
w.resize(400, 96)
w.set_view(10.0, 4.0)
w.set_selection(11.0, 13.0)
got = []
w.selection_changed.connect(lambda s, e: got.append((s, e)))
# simulate: press in empty area near t=10.5 -> new selection anchor,
# drag to t=13.5, release
w._begin_drag_at_x(w._t_to_px(10.5))
w._drag_to_x(w._t_to_px(13.5))
w._end_drag()
assert got, "selection_changed should fire on release"
s, e = w.selection()
assert 10.0 <= s < e <= 14.0
def test_waveform_zoom_emits_view(win):
w = win._wave
w.resize(400, 96)
w.set_view(10.0, 4.0)
seen = []
w.view_changed.connect(lambda s, d: seen.append((s, d)))
w._zoom_at_x(200, 0.5) # zoom in (factor<1) about the middle
assert seen and seen[-1][1] < 4.0 # span shrank
```
**Step 2** — run → FAIL.
**Step 3 — implement:**
- Add signals: `selection_changed = pyqtSignal(float, float)`, `view_changed = pyqtSignal(float, float)`.
- Helper `_t_to_px(t)` / `_px_to_t(x)` wrapping the core helpers with `self.width()`, `self._view_start`, `self._view_dur`.
- Refactor the drag into small methods the test can call: `_begin_drag_at_x(x)` (pick handle if within `HANDLE_PX`, else start new selection anchored at that time), `_drag_to_x(x)` (move active edge, clamp, `update()`), `_end_drag()` (finalize + `emit selection_changed`). `mousePressEvent`/`mouseMoveEvent`/`mouseReleaseEvent` delegate to these using `event.position().x()`.
- `_zoom_at_x(x, factor)`: `new_dur = clamp(self._view_dur*factor, MIN_SPAN, source-cap)`, keep the time under the pointer fixed, set `_view_start`/`_view_dur`, `update()`, `emit view_changed`. `wheelEvent` calls it with `factor=0.8`(in)/`1.25`(out) using the wheel delta sign.
**Step 4** — full UI file → new tests + prior pass.
**Step 5 — commit:** `feat: waveform drag-select + wheel zoom (selection_changed/view_changed)`
---
## Task W4: MainWindow wiring (two-way sync + re-decode)
**Files:** Modify `main.py`; test `tests/test_ui_structure.py`.
**Step 1 — failing test:**
```python
def test_waveform_selection_syncs_cursor_and_length(win):
win._file_path = "/x/video.mp4"
win._cursor = 10.0
win._spn_audio_len.setValue(3.0)
win._wave.set_view(10.0, 6.0)
win._wave.set_selection(11.0, 14.0)
win._on_wave_selection_changed(11.0, 14.0)
assert win._cursor == 11.0
assert abs(win._spn_audio_len.value() - 3.0) < 1e-6
```
**Step 2** — run → FAIL.
**Step 3 — implement:**
- Connect in `__init__` (where `_wave` is built): `self._wave.selection_changed.connect(self._on_wave_selection_changed)` and `self._wave.view_changed.connect(self._on_wave_view_changed)`.
- `_on_wave_selection_changed(s, e)`: set `self._cursor = s`; set the length spinbox to `e-s` (block its signal or let it update the band); call `_update_audio_region()` and seek the video to `s` if desired (optional). Guard a re-entrancy flag so programmatic selection sets don't loop.
- `_on_wave_view_changed(vs, vd)`: re-decode peaks for `[vs, vd]` via the existing capped `load_region_samples`+`peaks` (reuse `_on_wave_refresh`'s decode, factored into a small `_decode_wave(vs, vd)` helper) and `set_peaks`.
- Update `_on_wave_refresh` to also `set_view(self._cursor, view_dur)` and `set_selection(self._cursor, self._cursor+len)` (view_dur = max(len, MIN_VIEW)). Two-way: when `_spn_audio_len` changes by typing, update the selection to `[_cursor, _cursor+len]` (add to `_on_audio_len_changed`, guarded).
**Step 4** — full UI file → new test + prior pass; manually confirm no signal feedback loop (the re-entrancy guard).
**Step 5 — commit:** `feat: wire interactive waveform selection to cursor/length (two-way)`
---
## Task W5: Playhead during audition + verify + docs
**Files:** `main.py`, `README.md`; verify.
- **Playhead:** on audition start, start a `QTimer` (~50ms) setting `self._wave.set_playhead(sel_start + elapsed)`; stop + `set_playhead(None)` in `_teardown_audition`/`_stop_audition`. Elapsed via a stored start time (use a QElapsedTimer, not `time`/`Date`).
- **Docs:** README "Audio extraction & editing" gains an interactive-waveform bullet; changelog bump to a 1.5 entry ("Interactive waveform — drag to select the clip, zoom, playhead").
- **Verify:** each test file separately (expect 37+new passes; the 3 known failures only); import smoke.
- **Commit:** `feat: waveform playhead during audition + docs (v1.5)`
---
## Deferred to 3b / 3c
- Per-join crossfade + curves (3b); destructive clip editor (3c) — separate plans after 3a lands.
@@ -0,0 +1,72 @@
# Audio editor — "fancier" features (Phase 3) — Design
**Goal:** Three deferred editing capabilities on the Audio tab, phased:
- **3a — Interactive waveform selection** (foundation): drag in/out handles, zoom, moving playhead.
- **3b — Per-join crossfade + curves** in the Merge pane.
- **3c — Destructive clip editor**: cut/delete/silence/reverse a region with undo/redo.
**Builds on:** Phase 1/2 — `AudioWaveform` (read-only strip), `load_region_samples`/`peaks`, extract/audition via `_current_edit_filters`, the Merge pane + `build_crossfade_merge_command`.
**Runs in:** Python/Qt client + `core/`. No server/DB change.
**Order:** 3a first (unlocks precise selection everywhere), then 3b (small engine extension), then 3c (largest; reuses 3a's widget). Same `audio-tab` branch, reviewed per task.
---
## Phase 3a — Interactive waveform selection
Turn `AudioWaveform` into a selection surface that drives the extract region. Extract/audition/add-to-merge already use `_cursor` + `_spn_audio_len`, so they honor the dragged selection with no handler change.
**View + selection model (all seconds, absolute in source):**
- `_view_start`, `_view_dur` — the decoded, zoomable visible window (peaks are for this window, via the existing capped `load_region_samples`).
- `_sel_start`, `_sel_end` — the selection within the window.
- Pixel↔time helpers (pure, unit-tested): `t = _view_start + (x/width)·_view_dur`; `x = (t_view_start)/_view_dur·width`.
**Interaction:**
- Press within `HANDLE_PX` of an in/out handle → drag that edge; press elsewhere → start a new selection at that time, drag to extend.
- Move → update the active edge, clamp to the window, keep `start < end`, repaint.
- Release → emit `selection_changed(sel_start, sel_end)`.
- Wheel → zoom `_view_dur` about the pointer time (clamped to a min span and source length) → emit `view_changed(view_start, view_dur)`.
- Height grows 48→~96 for usable dragging.
**Painting (extends current):** peak bars + a translucent selection band + two bright handle lines + an optional playhead line.
**Signals:** `selection_changed(float,float)`, `view_changed(float,float)`.
**MainWindow wiring:**
- Refresh (↻ / on load): decode `[_cursor, _cursor+view_dur]` (view_dur defaults to max(length, a few s)); `set_view(...)`, `set_peaks(...)`, selection = `[_cursor, _cursor+len]`.
- `selection_changed` → set `_cursor = sel_start` and `_spn_audio_len = sel_endsel_start` (which already refreshes the timeline band); block signals while syncing to avoid feedback.
- Typing `_spn_audio_len` → update the waveform selection (two-way sync).
- `view_changed` → re-decode peaks for the new window (throttled, capped decode).
**Playhead (optional within 3a):** during audition a QTimer sets `_playhead = sel_start + elapsed` (ffplay gives no position; elapsed-based is close enough), cleared on stop. Deferrable if it complicates.
**Tests:** unit-test the pixel↔time round-trip + clamping (pure); smoke-test that a simulated drag emits `selection_changed` and syncs `_cursor`/length, and that typing a length moves the selection.
---
## Phase 3b — Per-join crossfade + curves
- Merge items gain a per-join crossfade + curve (stored `UserRole+2` = float, `UserRole+3` = curve name). New joins default from the global `_spn_crossfade` + a new curve combo; double-click a row to override that join.
- `build_crossfade_merge_command` extended: accept `crossfades: float | list[float]` (scalar broadcasts to every join — backward compatible) and `curve: str` (acrossfade `c1=/c2=`; e.g. `tri`/`exp`/`log`/`qsin`). Join *i*`acrossfade=d={xf[i]}:c1={curve}:c2={curve}`.
- The duration guard checks each clip against its own join's crossfade.
---
## Phase 3c — Destructive clip editor (largest)
A modal editor over ONE file (the current selection rendered to a temp, a merge output, or an opened file). Detailed plan authored after 3a/3b land.
- Full-file interactive waveform (reuse the 3a widget) + a selection.
- Region ops, each rendering a NEW temp version (non-in-place → clean undo):
- **Delete** — `atrim`+`concat` of `[0,selStart]`+`[selEnd,end]`.
- **Silence** — `volume=0`/replace over the region.
- **Reverse** — `areverse` (segment via `atrim`+`areverse`+`concat`).
- **Trim to selection** — keep only `[selStart,selEnd]`.
- **Fade selection** — `afade` on the region.
- **Undo/redo:** a stack of rendered version files; undo pops to the prior file, redo re-applies. Robust and simple.
- **Save / Save-as.** Pure per-op command builders in `core/ffmpeg.py` (TDD); the dialog wires them.
---
## What this does NOT do
- No multi-track timeline / overlap mixing; no spectral editing; no saved editor projects (session/file-based only).
+118
View File
@@ -0,0 +1,118 @@
# Audio merge (crossfade) — Phase 2 design
**Goal:** Let the user **easily assemble multiple audio clips into one output with
crossfades at the joins** — directly from the Audio tab — with room to grow into
fancier assembly (per-join curves, gain automation, effects). This is the
"audio editor" the user asked for; its north star is *crossfade merging*.
**Runs in:** Python/Qt client (`main.py`) + `core/ffmpeg.py`. No server/DB change.
**Builds on:** Phase 1 (the Audio QToolBox tab, `build_audio_clip_command`,
`audio_edit_filters`, the waveform strip, and ffplay audition).
---
## Priority / ordering
The stated goal is crossfade merging, so that lands first. The interactive
drag-select waveform (approved separately) is a *precision clip-picker* that
feeds the merge — valuable but not required for a working merge (whole
clips/files merge fine). Ordering:
- **Phase 2 (this doc):** the **Merge pane** + crossfade engine. Clips are added
from the current extract selection (rendered with its edits) or from files on
disk.
- **Phase 2-later:** interactive waveform selection (drag in/out handles, zoom,
moving playhead) as the way to define each added clip precisely; then "fancier
stuff" (per-join crossfade curves, per-clip gain, reorder-by-drag).
---
## Engine — `core/ffmpeg.py`
`build_crossfade_merge_command(clips: list[str], crossfade: float, out_path: str) -> list[str]`
- **0 clips** → `ValueError` (caller guards; never invoked empty).
- **1 clip** → straight re-encode to `out_path`'s format: `-i c0 -vn <codec> out`.
- **≥2 clips, crossfade > 0** → chain ffmpeg `acrossfade` (available; `N->A`):
```
-i c0 -i c1 ... -i c{n-1}
-filter_complex
"[0][1]acrossfade=d=D[a1];[a1][2]acrossfade=d=D[a2];…;[a{n-2}][{n-1}]acrossfade=d=D[out]"
-map "[out]" <codec> out
```
(single global crossfade `D` on every join in v1; per-join comes later).
For exactly 2 clips the label is `[out]` directly (no intermediate).
- **≥2 clips, crossfade == 0** → butt-join via the `concat` filter
(`concat=n=N:v=0:a=1`) so a zero-crossfade merge still works.
Codec chosen by `out_path` extension via the existing `_AUDIO_CODEC_BY_EXT`
(reuse — same formats as extract: wav/mp3/flac/m4a/ogg/opus).
Note: acrossfade needs each pair to share a sample format; ffmpeg auto-negotiates
via the filtergraph, but if mixed-rate inputs cause trouble we insert `aresample`
before each input. Start without it; add only if a real mismatch shows up.
Pure function → fully unit-tested (no ffmpeg run needed).
---
## UI — a third QToolBox pane: **Merge**
Added to `_build_audio_tab` after "Extract & Edit" and "Scan / Classify".
**Widgets (constructed in `__init__`):**
- `_merge_list` — a `QListWidget` (reorderable) of clips to merge, in order. Each
row shows the basename + duration (probed once on add). The clip's absolute
path is stored on the item (`Qt.UserRole`).
- `_spn_crossfade` — `QDoubleSpinBox`, 0.010.0 s, step 0.1, default 0.5 s, suffix
" s". One global crossfade applied to every join in v1.
- Buttons: ** Selection** (add the current extract area, rendered with the edit
chain, to a temp clip and append), ** File…** (`QFileDialog` multi-select audio
files), **▲ / ▼** (reorder selected row), **✕** (remove selected),
**▶ Preview** (merge to a temp file and audition via the Phase-1 ffplay path),
**Merge & Save…** (render + save-as).
**Handlers:**
- ** Selection** — reuse `build_audio_clip_command(self._file_path, cursor, len,
tmp, filters=self._current_edit_filters() or None)` → temp wav in the app temp
dir (unique name per add) → append to `_merge_list`. Guards on a loaded file.
- ** File…** — append each chosen path.
- **▲/▼/✕** — list reorder/remove.
- **Merge & Save…** (`_on_merge_save`) — collect the ordered paths; if <1 clip,
status + return; build via `build_crossfade_merge_command(paths,
_spn_crossfade.value(), out)`; `QFileDialog.getSaveFileName` (same format
filter as extract, remembered dir); `subprocess.run` under a wait cursor +
status; report saved length via `probe_duration`, mirroring extract's success/
error reporting.
- **▶ Preview** — same build to a temp file, then start the existing audition
QProcess(ffplay) on it (reuse `_stop_audition`/teardown machinery, or a small
shared `_play_file(path)`).
**Temp files:** rendered selection-clips and preview output live in the system
temp dir with unique names; a session set tracks them and `closeEvent` best-effort
removes them (extends the Phase-1 audition teardown).
---
## Persistence & migration
- QSettings gains `audio_crossfade` (last crossfade value). No DB/schema change.
- The merge sequence is **session-only** (not persisted) in v1 — it's a scratch
assembly surface, not a saved project. (Saved projects = "fancier stuff".)
## Testing
- `tests/test_utils.py`: TDD `build_crossfade_merge_command` — 1-clip re-encode,
2-clip acrossfade (`-filter_complex` contains `acrossfade=d=0.5`, maps `[out]`),
3-clip chained (two acrossfade stages), crossfade==0 → `concat`, codec-by-ext,
0-clip → ValueError.
- `tests/test_ui_structure.py`: Merge pane exists as a 3rd QToolBox page;
`_merge_list`/`_spn_crossfade`/buttons present; add-file appends a row;
remove/reorder mutate the list; `_on_merge_save` with an empty list is a safe
no-op.
## What this does NOT do (v1)
- No per-join crossfade durations or curve selection (single global value).
- No interactive waveform drag-select yet (whole-clip/selection granularity).
- No saved/reloadable merge projects (session-only sequence).
- No multi-track mixing/overlap beyond the crossfade at joins.
- No DB/dataset wiring (that's the deferred Phase 2 "dataset" work, separate).
@@ -0,0 +1,224 @@
# Audio Merge (Crossfade) — Phase 2 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** A "Merge" pane in the Audio tab that assembles an ordered list of audio clips into one output with a crossfade at every join, rendered via ffmpeg `acrossfade`. Clips come from the current extract selection (with its edits) or from files on disk.
**Architecture:** A pure `core/ffmpeg.build_crossfade_merge_command` (TDD) + a third `QToolBox` page in `_build_audio_tab`. Reuses Phase-1 pieces: `build_audio_clip_command`, `_current_edit_filters`, `probe_duration`, and the ffplay audition machinery (`_audition_proc`/`_stop_audition`/`_teardown_audition`).
**Tech Stack:** Python 3, PyQt6, ffmpeg (`acrossfade`/`concat` filters), pytest.
**Design doc:** `docs/plans/2026-07-02-audio-merge-design.md`
---
## Conventions
- Branch `audio-tab` (continues Phase 1). New commit per task; don't amend across tasks.
- UI tests: `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v`. Run `test_ui_structure.py` and `test_utils.py` in SEPARATE processes (known segfault when combined). Pre-existing red in `test_utils.py`: `test_audio_extract_timing`, `test_ffmpeg_command_no_resize`, `test_db_get_markers_returns_sorted` — untouched by this work.
---
## Task M1: `build_crossfade_merge_command` (pure, TDD)
**Files:** Modify `core/ffmpeg.py`; test `tests/test_utils.py`.
**Step 1 — failing tests:**
```python
def test_merge_single_clip_reencodes():
cmd = build_crossfade_merge_command(["/a.wav"], 0.5, "/o/out.mp3")
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
assert "libmp3lame" in cmd # codec by out ext
assert "acrossfade" not in " ".join(cmd)
assert cmd[-1] == "/o/out.mp3"
def test_merge_two_clips_acrossfade():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/out.wav")
assert cmd.count("-i") == 2
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5" in fc
assert "[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_merge_three_clips_chains():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert fc.count("acrossfade=d=1.0") == 2 # two joins
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_merge_zero_crossfade_uses_concat():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "concat=n=2:v=0:a=1" in fc
assert "acrossfade" not in fc
def test_merge_empty_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command([], 0.5, "/o/o.wav")
```
**Step 2** — run `...pytest tests/test_utils.py -k merge -v` → FAIL.
**Step 3 — implement** in `core/ffmpeg.py` (near `build_audio_clip_command`):
```python
def build_crossfade_merge_command(clips: list[str], crossfade: float,
out_path: str) -> list[str]:
"""ffmpeg command that concatenates *clips* in order into *out_path*,
crossfading each join by *crossfade* seconds (0 = butt-join). Re-encoded
per the output extension."""
if not clips:
raise ValueError("no clips to merge")
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
cmd = [_bin("ffmpeg"), "-y"]
for c in clips:
cmd += ["-i", c]
if len(clips) == 1:
return cmd + ["-vn", *codec, out_path]
if crossfade > 0:
# Chain acrossfade: [0][1]->[a1]; [a1][2]->[a2]; … last label = [out].
parts, prev = [], "0"
for i in range(1, len(clips)):
label = "out" if i == len(clips) - 1 else f"a{i}"
parts.append(f"[{prev}][{i}]acrossfade=d={crossfade}[{label}]")
prev = label
fc = ";".join(parts)
else:
inputs = "".join(f"[{i}]" for i in range(len(clips)))
fc = f"{inputs}concat=n={len(clips)}:v=0:a=1[out]"
return cmd + ["-filter_complex", fc, "-map", "[out]", *codec, out_path]
```
Confirm `_bin` import in ffmpeg.py (it uses `_bin` already).
**Step 4**`...pytest tests/test_utils.py -k "merge or audio_clip" -v` → PASS (5 new + existing audio-clip tests).
**Step 5 — commit:** `git add core/ffmpeg.py tests/test_utils.py && git commit -m "feat: build_crossfade_merge_command — chained acrossfade / concat"`
---
## Task M2: Merge pane widgets + 3rd QToolBox page
**Files:** Modify `main.py`; test `tests/test_ui_structure.py`.
**Step 1 — failing test:**
```python
def test_merge_pane_present(win):
from PyQt6.QtWidgets import QToolBox, QListWidget, QDoubleSpinBox
tb = win._tab_audio.findChild(QToolBox)
titles = [tb.itemText(i) for i in range(tb.count())]
assert "Merge" in titles
assert isinstance(win._merge_list, QListWidget)
assert isinstance(win._spn_crossfade, QDoubleSpinBox)
assert win._spn_crossfade.value() == 0.5
```
**Step 2** — run → FAIL.
**Step 3 — implement:**
- In `__init__` (after the audio-edit widget block), construct: `self._merge_list = QListWidget()` (set `setSelectionMode(SingleSelection)`); `self._spn_crossfade = QDoubleSpinBox()` (range 0.010.0, decimals 2, step 0.1, suffix " s", value from QSettings `audio_crossfade` default 0.5, persist on change); the buttons `self._btn_merge_add_sel` (" Selection"), `self._btn_merge_add_file` (" File…"), `self._btn_merge_up` ("▲"), `self._btn_merge_down` ("▼"), `self._btn_merge_remove` ("✕"), `self._btn_merge_preview` ("▶ Preview"), `self._btn_merge_save` ("Merge & Save…"). Wire each `clicked` to its handler (defined in M3/M4; add stub methods now that `pass` — or define fully in later tasks and reference them). To keep TDD green, define minimal stub handlers now.
- In `_build_audio_tab`, add a third page: a `QWidget` with a `QVBoxLayout` holding the list, a crossfade row (`QLabel("Crossfade:")` + `_spn_crossfade`), a button grid (add/remove/reorder), and a bottom row (Preview, Merge & Save). `tb.addItem(merge_page, "Merge")`.
**Step 4** — run full `tests/test_ui_structure.py` → new test passes, prior 27 still pass.
**Step 5 — commit:** `feat: Merge pane (3rd Audio QToolBox page) — list + crossfade + buttons`
---
## Task M3: Add-to-sequence + reorder/remove
**Files:** `main.py`; test `tests/test_ui_structure.py`.
**Step 1 — failing test** (drives the list ops without file dialogs):
```python
def test_merge_list_add_remove_reorder(win, tmp_path):
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"
a.write_bytes(b""); b.write_bytes(b"")
win._merge_add_paths([str(a), str(b)])
assert win._merge_list.count() == 2
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(a)
win._merge_list.setCurrentRow(1)
win._merge_move(-1) # move b up
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(b)
win._merge_list.setCurrentRow(0)
win._merge_remove_selected()
assert win._merge_list.count() == 1
```
**Step 2** — run → FAIL.
**Step 3 — implement** on MainWindow:
- `_merge_add_paths(paths)` — for each path append a `QListWidgetItem` with text `basename (Ds)` (duration via `probe_duration`, blank if None) and `setData(UserRole, abspath)`.
- `_on_merge_add_file``QFileDialog.getOpenFileNames` (audio filter, remembered dir) → `_merge_add_paths`.
- `_on_merge_add_selection` — guard `_file_path`; render `[_cursor, _cursor+len]` with `_current_edit_filters()` to a unique temp wav (track it for cleanup) via `build_audio_clip_command` + `subprocess.run` under wait cursor; on success `_merge_add_paths([tmp])`; else status.
- `_merge_move(delta)` and `_merge_remove_selected()` — standard list row moves/removal.
- Replace the M2 stubs for add/file/selection/up/down/remove with these (up=`_merge_move(-1)`, down=`_merge_move(1)`).
**Step 4** — run full UI file → new test + prior pass.
**Step 5 — commit:** `feat: Merge sequence — add (file/selection), reorder, remove`
---
## Task M4: Merge & Save + Preview
**Files:** `main.py`; test `tests/test_ui_structure.py`.
**Step 1 — failing test** (patches the builder to capture args; sentinel-raises to avoid running ffmpeg):
```python
def test_merge_save_builds_command(win, tmp_path, monkeypatch):
import main as m, pytest
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"")
win._merge_add_paths([str(a), str(b)])
win._spn_crossfade.setValue(0.75)
seen = {}
class _Stop(Exception): pass
def fake(clips, xf, out):
seen["clips"] = clips; seen["xf"] = xf; seen["out"] = out
raise _Stop
monkeypatch.setattr(m, "build_crossfade_merge_command", fake)
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), "")))
with pytest.raises(_Stop):
win._on_merge_save()
assert seen["clips"] == [str(a), str(b)]
assert seen["xf"] == 0.75
assert seen["out"].endswith(".wav")
def test_merge_save_empty_is_noop(win):
win._merge_list.clear()
win._on_merge_save() # must not raise / not open a dialog
```
**Step 2** — run → FAIL.
**Step 3 — implement:**
- `_merge_paths()` helper → list of UserRole paths in order.
- `import build_crossfade_merge_command` at the top with the other ffmpeg imports.
- `_on_merge_save` — if `_merge_paths()` empty → status + return; save dialog (reuse the extract save-filter builder / remembered dir); `cmd = build_crossfade_merge_command(paths, self._spn_crossfade.value(), out)`; run under wait cursor + status; on success report `probe_duration`, mirror extract's success/error handling.
- `_play_file(path)` — extract the ffplay-start logic from `_on_audio_audition` into this shared helper (render→play stays in audition; `_play_file` just starts `QProcess(ffplay)` on an existing file and reuses `_stop_audition`/teardown). Have `_on_audio_audition` call `_play_file(tmp)` after its render.
- `_on_merge_preview` — build to a unique temp wav, run, then `_play_file(tmp)`; guard empty list.
**Step 4** — run full UI file → new tests + prior pass; also re-run the audition test to confirm the `_play_file` refactor didn't break it.
**Step 5 — commit:** `feat: merge save + preview (crossfade render, ffplay preview)`
---
## Task M5: Temp cleanup, verify, docs
**Files:** `main.py`, `README.md`; verify.
- **Temp cleanup:** track rendered temp clips/preview outputs in a `self._merge_temps: set[str]`; best-effort `os.remove` them in `closeEvent` (add next to the audition teardown). Add `_stop_audition()` to `closeEvent` too (Phase-1 code-review follow-up).
- **Docs:** bump `APP_VERSION` "1.3"→"1.4"; add a `("1.4", [...])` changelog entry describing the Merge pane (assemble clips, crossfade at joins, add from selection/file, preview, save). README: add a bullet group under "Audio extraction & editing" for **Merge / crossfade**.
- **Verify:** run `tests/test_utils.py`, `tests/test_ui_structure.py`, `tests/test_audio_scan.py`, `tests/test_db.py` each in its own process; report counts; confirm the only failures are the 3 known pre-existing ones. `python -c "import main; print('OK')"` smoke.
- **Commit:** `docs: Merge pane (Phase 2) — v1.4 changelog + README + temp cleanup`
---
## Deferred (Phase 2-later / "fancier stuff")
- Interactive waveform drag-select (in/out handles, zoom, moving playhead) to define each added clip precisely.
- Per-join crossfade durations + curve selection (`c1=/c2=`), per-clip gain, drag-to-reorder.
- Saved/reloadable merge projects.
+155
View File
@@ -0,0 +1,155 @@
# Audio workspace tab — Design
**Goal:** Promote audio from a pair of cramped transport-row controls to a
first-class **Audio** deck tab that unifies everything audio: exact-length
extraction, light non-destructive editing of the extracted sound, an
audio-dedicated dataset workflow, and the existing scan/classify controls.
**Runs in:** Python/Qt client (`main.py`) + `core/` helpers. No server changes.
**Approach:** Replace the current **Scan** deck tab with an **Audio** tab.
Because the tab now covers four concerns, its body is a `QToolBox` accordion
(one pane open at a time) rather than flat stacked group-boxes. Existing
scan/extraction widgets are re-parented (same handlers/signals) — no behavior
change beyond relocation.
**Deck order after:** `Export | Crop & Track | Audio`. The `ScanResultsPanel`
(3rd splitter pane) is untouched.
Delivered in **two phases**. Phase 1 is self-contained and shippable; Phase 2
adds the dataset workflow.
---
## Deck wiring (both phases)
`_build_control_deck` (main.py:4661): swap `_tab_scan``_tab_audio`
`_deck_key="audio"`, `_label="Audio"`, `_pinned=False`; replace it in
`_deck_panels`; one `addTab`. The pin/split machinery is `_deck_panels`-driven,
so it picks the new panel up automatically. Old persisted `"scan"` pin-state is
ignored (harmless). Nothing auto-switches to the old scan tab — verified:
`_tab_scan` is referenced only in construction + its builder.
`_build_scan_tab` is renamed `_build_audio_tab` and rebuilt as a `QToolBox`
with the panes below.
---
## Phase 1 — Extract, Edit, Classify
### Pane 1 — Extract & Edit
**Relocated from the transport row** (removed there): `_spn_audio_len` (length)
and `_btn_extract_audio` (`♪ Extract`). The teal timeline region band is
unaffected — still driven by `_spn_audio_len`.
**New controls:**
- `_cmb_audio_fmt` — output format (WAV / MP3 / FLAC / M4A / OGG / OPUS),
persisted to QSettings `audio_extract_fmt`; pre-selects the save-dialog filter
so the format isn't re-picked each time.
- **Waveform strip** — a compact widget rendering the current region (or
last-extracted clip). Generated from decoded PCM (reuse
`core/audio_scan._load_audio_ffmpeg`) or `ffmpeg showwavespic`. Read-only in
v1 (no drag handles).
- **Play / Stop** — audition the current region/clip. Playback backend decided
in the plan (likely a short ffmpeg-decoded buffer through a
`QMediaPlayer`/`QSoundEffect`, kept separate from the mpv video player).
**Editing (non-destructive, ffmpeg filter chain at render time):**
| Control | Filter |
|---|---|
| Trim in / out (refine past the length spinbox) | `atrim` / adjusted `-ss` / `-t` |
| Fade in / out (s) | `afade=t=in` / `afade=t=out` |
| Normalize (toggle) | `loudnorm` |
| Gain (dB) | `volume` |
`core/ffmpeg.build_audio_clip_command` gains an optional `filters` param →
appended as `-af "<chain>"`. Foley/existing callers pass none → byte-identical
output. Extract applies the current edit settings; nothing is written until you
save.
### Pane 2 — Scan / Classify
Relocated verbatim from the old Scan tab: model combo + `_btn_model_history`;
`_btn_scan`, `_btn_auto_export`, `_btn_speech`, `_btn_scan_mode`;
`_spn_auto_fuse`, `_sld_threshold`. Same handlers, same `ScanResultsPanel`
output.
*(Train / Hard-negatives / Dataset-stats buttons join Pane 3 in Phase 2; in
Phase 1 they stay wherever they are today — the menu bar.)*
---
## Phase 2 — Audio dataset
### Pane 3 — Dataset (audio-dedicated)
A dedicated audio dataset **workflow + view**, combining browser-plus-label,
add-from-extraction, and in-tab access to the existing tools.
**Key data-model finding (the one open decision — confirm before building):**
the existing classifier trains on **(source_video, time-ranges)** per profile —
`db.get_training_data(...)` returns `(source_video_path, positive_times,
soft_times, negative_times)` per video; standalone clip *files* are not the
training unit. An extracted audio region always knows its
`(source_video, start, end)`. So the cheapest, most powerful integration is
**merged**: "Add to dataset" records the region as a labeled positive/negative
time-range in the **same** per-profile DB the video-export workflow already
feeds — no new trainer, no schema change. The Audio dataset pane is then an
**audio-oriented view/filter** over that shared dataset.
- **Recommended (merged):** audio extraction becomes another way to label
training data; reuses `get_training_data`, `get_training_stats`,
hard-negatives, and `train_classifier` unchanged.
- **Alternative (separate):** a standalone folder of `.wav` clips + own
`dataset.json` + a new file-based training path. More code, duplicates the
trainer. Only needed if the audio set must stay entirely independent of the
video profiles.
*(Brainstorming default was "separate"; inspecting the DB flips the
recommendation to "merged." Flagged for confirmation.)*
**Pane contents (assuming merged):**
- **Add to dataset** — from the current/last extraction:
`[class ▾]` + `(•positive / ○negative)` + `[+ Add]`. Writes a labeled
time-range into the DB for the active profile (and, for standalone-clip
parity, writes the clip's `dataset.json` label via `upsert_clip_annotation`
when a file was saved).
- **Browser** — a list of the audio dataset entries (class, pos/neg, source,
time-range), filterable by class; select to seek the source video to that
region; remove entries.
- **Tools** — in-tab buttons for the existing dialogs: *Dataset Statistics*
(`DatasetStatsDialog`), *Manage Hard Negatives* (`HardNegativesDialog`),
*Train classifier* (existing Train dialog).
---
## Persistence & migration
- QSettings gains `audio_extract_fmt` (+ edit defaults: fade/normalize/gain)
alongside existing `audio_extract_len`, `audio_extract_dir`.
- Deck pin-state key `"scan"``"audio"`; absent → default unpinned.
- Phase 2 (merged): no schema change — reuses existing training tables /
`dataset.json`. Phase 2 (separate) would add a folder convention only.
## Testing
- `tests/test_ui_structure.py`: update `test_extract_audio_controls_exist` to
locate controls in the Audio tab; add a test that the Audio deck tab exists as
a `QToolBox` with Extract/Classify panes and that scan controls remain
reachable.
- `tests/test_utils.py`: extend the audio-command tests for the new `-af`
filter chain (trim/fade/normalize/gain), keeping existing codec-by-extension
assertions.
- Phase 2: a DB test that "add to dataset" produces a training-data row
consumable by `get_training_data`.
## What this does NOT do
- No full waveform-drag editor in v1 — editing is parametric
(trim/fade/normalize/gain).
- No change to the Foley/LTX-2 export pipeline or `ScanResultsPanel`.
- No new ML model or embedding change.
- Phase 1 makes no DB/schema change; the dataset-model decision is confined to
Phase 2.
@@ -0,0 +1,648 @@
# Audio Workspace Tab — Phase 1 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the **Scan** control-deck tab with a first-class **Audio** tab (a `QToolBox` with *Extract & Edit* and *Scan / Classify* panes), relocate the extract controls off the transport row, add a persistent output-format picker, and add non-destructive parametric editing (fade / normalize / gain via an ffmpeg `-af` chain, plus trim by adjusting the extract window) with a read-only waveform strip and play/stop audition.
**Architecture:** Pure `core/ffmpeg.py` helper for the filter chain (fully unit-tested). `main.py` renames `_tab_scan``_tab_audio`, rebuilds its body as a `QToolBox`, moves the existing extract widgets into it, and adds the new edit widgets. No DB/schema change; Phase 2 (audio dataset) is deferred. The Foley/LTX-2 export path and `ScanResultsPanel` are untouched.
**Tech Stack:** Python 3, PyQt6, ffmpeg/ffprobe (via `core/ffmpeg.py`), pytest. UI tests use the existing `win` fixture in `tests/test_ui_structure.py` (offscreen Qt).
**Design doc:** `docs/plans/2026-07-02-audio-tab-design.md`
---
## Conventions
- Run one test: `pytest tests/test_utils.py::test_name -v`
- Run UI tests: `pytest tests/test_ui_structure.py -v` (needs the offscreen/LD_PRELOAD env the repo already uses — see `conftest.py`).
- Commit after every green step. Prefix messages `feat:` / `test:` / `refactor:`.
- Work on branch `audio-tab` (already created; design doc already committed there).
---
## Task 1: ffmpeg filter-chain helper + `filters` param
Non-destructive editing is applied at render time as an ffmpeg `-af` chain. The
chain builder is a pure function → fully unit-testable without invoking ffmpeg.
Trim is **not** a filter: trim-in/out just adjust the `start`/`duration` passed
to `build_audio_clip_command` (done in Task 5), so this task only covers
fade / normalize / gain.
**Files:**
- Modify: `core/ffmpeg.py` (add `audio_edit_filters`, extend `build_audio_clip_command` at lines 204-218)
- Test: `tests/test_utils.py` (after the existing audio-clip tests, ~line 77)
**Step 1: Write the failing tests**
```python
def test_audio_edit_filters_empty_when_defaults():
from core.ffmpeg import audio_edit_filters
assert audio_edit_filters(duration=3.0) == []
def test_audio_edit_filters_fade_normalize_gain():
from core.ffmpeg import audio_edit_filters
f = audio_edit_filters(duration=10.0, fade_in=0.5, fade_out=2.0,
normalize=True, gain_db=-3.0)
assert "afade=t=in:st=0:d=0.5" in f
# fade-out starts at duration - fade_out
assert "afade=t=out:st=8.0:d=2.0" in f
assert "loudnorm" in f
assert "volume=-3.0dB" in f
def test_audio_clip_command_no_filters_unchanged():
# No filters -> byte-identical to today's command (no -af).
cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav")
assert "-af" not in cmd
def test_audio_clip_command_appends_filter_chain():
cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav",
filters=["afade=t=in:st=0:d=0.5", "loudnorm"])
i = cmd.index("-af")
assert cmd[i + 1] == "afade=t=in:st=0:d=0.5,loudnorm"
# filter chain sits before the output path, after -vn/codec
assert i < len(cmd) - 1 and cmd[-1] == "/o/a.wav"
```
**Step 2: Run to verify they fail**
Run: `pytest tests/test_utils.py -k "audio_edit_filters or filter_chain or no_filters" -v`
Expected: FAIL (`audio_edit_filters` not defined; `filters` is an unexpected kwarg).
**Step 3: Implement**
Add above `build_audio_clip_command` in `core/ffmpeg.py`:
```python
def audio_edit_filters(duration: float, fade_in: float = 0.0,
fade_out: float = 0.0, normalize: bool = False,
gain_db: float = 0.0) -> list[str]:
"""Compose an ffmpeg -af chain for the non-destructive audio edits.
Trim is handled by the caller (it adjusts start/duration), so only
fade / normalize / gain appear here. Returns [] when nothing is set, so
the extract command stays byte-identical to the un-edited case."""
chain: list[str] = []
if fade_in > 0:
chain.append(f"afade=t=in:st=0:d={fade_in}")
if fade_out > 0:
st = max(0.0, duration - fade_out)
chain.append(f"afade=t=out:st={st}:d={fade_out}")
if normalize:
chain.append("loudnorm")
if gain_db != 0.0:
chain.append(f"volume={gain_db}dB")
return chain
```
Extend `build_audio_clip_command`:
```python
def build_audio_clip_command(input_path: str, start: float, duration: float,
out_path: str,
filters: "list[str] | None" = None) -> list[str]:
"""ffmpeg command to extract exactly *duration* seconds of audio starting
at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…).
*filters* (if any) are joined into a single -af chain."""
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
af = ["-af", ",".join(filters)] if filters else []
return [
_bin("ffmpeg"), "-y",
"-ss", str(start),
"-i", input_path,
"-t", str(duration),
"-vn",
*codec,
*af,
out_path,
]
```
**Step 4: Run to verify pass**
Run: `pytest tests/test_utils.py -k audio -v`
Expected: PASS (new tests + the existing `test_audio_clip_command_*` still green).
**Step 5: Commit**
```bash
git add core/ffmpeg.py tests/test_utils.py
git commit -m "feat: audio_edit_filters helper + optional -af chain on build_audio_clip_command"
```
---
## Task 2: Rename the Scan deck tab → Audio (structure only)
Pure rename/re-key of the deck panel. The body still holds the scan grid for now
(Task 4 replaces it with the `QToolBox`). This isolates the deck-wiring change
from the layout change.
**Files:**
- Modify: `main.py` imports (line ~22), `_build_control_deck` (4666-4685), the builder call at 4498, and `_build_scan_tab` def (4745)
- Test: `tests/test_ui_structure.py` (new test near the audio tests, ~line 274)
**Step 1: Write the failing test**
```python
def test_audio_deck_tab_exists(win):
# The old "Scan" deck tab is now "Audio".
assert hasattr(win, "_tab_audio")
assert win._tab_audio._deck_key == "audio"
assert win._tab_audio in win._deck_panels
assert not hasattr(win, "_tab_scan")
labels = [win._control_deck.tabText(i)
for i in range(win._control_deck.count())]
assert "Audio" in labels and "Scan" not in labels
```
**Step 2: Run to verify it fails**
Run: `pytest tests/test_ui_structure.py::test_audio_deck_tab_exists -v`
Expected: FAIL (`_tab_scan` still present; no `_tab_audio`).
**Step 3: Implement**
1. Add `QToolBox, QGroupBox` to the `PyQt6.QtWidgets` import block (line ~22, next to `QTabWidget, QTabBar`):
```python
QTableWidget, QTableWidgetItem, QTabWidget, QTabBar, QHeaderView,
QToolBox, QGroupBox,
QGridLayout,
```
2. In `_build_control_deck`, replace the three `_tab_scan` lines (4668, 4678-4680) and the `_deck_panels`/`addTab` lines (4682, 4685):
```python
self._tab_audio = QWidget(); self._tab_audio.setObjectName("audio_tab")
...
self._tab_audio._pinned = False
self._tab_audio._label = "Audio"
self._tab_audio._deck_key = "audio"
# Ordered list for deterministic column / tab order.
self._deck_panels = [self._tab_export, self._tab_crop, self._tab_audio]
deck.addTab(self._tab_export, self._tab_export._label)
deck.addTab(self._tab_crop, self._tab_crop._label)
deck.addTab(self._tab_audio, self._tab_audio._label)
```
3. Rename the method `def _build_scan_tab(self)``def _build_audio_tab(self)` and change `QGridLayout(self._tab_scan)``QGridLayout(self._tab_audio)` (temporary; Task 4 rewrites the body).
4. Change the call at line 4498 `self._build_scan_tab()``self._build_audio_tab()`.
**Step 4: Run to verify pass**
Run: `pytest tests/test_ui_structure.py -v`
Expected: PASS (new test green; existing deck/pin tests still green).
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "refactor: rename Scan deck tab -> Audio (deck wiring only)"
```
---
## Task 3: Construct the new Audio widgets; drop extract controls from the transport row
Build the new edit widgets in `__init__` next to the existing extract controls
(~line 4438-4464), and stop adding `_spn_audio_len` / `_btn_extract_audio` to the
transport row (they move into the tab in Task 4). Keep the widgets as instance
attributes so `_build_audio_tab` can place them.
**Files:**
- Modify: `main.py` transport-row block (4441-4464)
- Test: `tests/test_ui_structure.py`
**Step 1: Write the failing test**
```python
def test_audio_edit_widgets_exist(win):
from PyQt6.QtWidgets import QComboBox, QCheckBox, QDoubleSpinBox
assert isinstance(win._cmb_audio_fmt, QComboBox)
assert win._cmb_audio_fmt.count() >= 3 # wav/mp3/flac at least
assert isinstance(win._spn_fade_in, QDoubleSpinBox)
assert isinstance(win._spn_fade_out, QDoubleSpinBox)
assert isinstance(win._chk_normalize, QCheckBox)
assert isinstance(win._spn_gain, QDoubleSpinBox)
```
**Step 2: Run to verify it fails**
Run: `pytest tests/test_ui_structure.py::test_audio_edit_widgets_exist -v`
Expected: FAIL (attributes don't exist).
**Step 3: Implement**
After the `_btn_extract_audio` construction (~line 4461), add:
```python
# Output format (persisted) — drives the extract extension + save filter.
self._cmb_audio_fmt = QComboBox()
for label, ext in (("WAV", ".wav"), ("MP3", ".mp3"), ("FLAC", ".flac"),
("M4A", ".m4a"), ("OGG", ".ogg"), ("OPUS", ".opus")):
self._cmb_audio_fmt.addItem(label, ext)
_saved_fmt = self._settings.value("audio_extract_fmt", ".wav")
_idx = self._cmb_audio_fmt.findData(_saved_fmt)
if _idx >= 0:
self._cmb_audio_fmt.setCurrentIndex(_idx)
self._cmb_audio_fmt.currentIndexChanged.connect(
lambda _i: self._settings.setValue(
"audio_extract_fmt", self._cmb_audio_fmt.currentData()))
# Non-destructive edit controls (applied via ffmpeg -af at extract time).
self._spn_fade_in = QDoubleSpinBox()
self._spn_fade_in.setRange(0.0, 30.0); self._spn_fade_in.setDecimals(2)
self._spn_fade_in.setSingleStep(0.1); self._spn_fade_in.setSuffix(" s")
self._spn_fade_in.setToolTip("Fade-in duration (0 = none)")
self._spn_fade_out = QDoubleSpinBox()
self._spn_fade_out.setRange(0.0, 30.0); self._spn_fade_out.setDecimals(2)
self._spn_fade_out.setSingleStep(0.1); self._spn_fade_out.setSuffix(" s")
self._spn_fade_out.setToolTip("Fade-out duration (0 = none)")
self._chk_normalize = QCheckBox("Normalize")
self._chk_normalize.setToolTip("Apply EBU R128 loudness normalization (loudnorm)")
self._spn_gain = QDoubleSpinBox()
self._spn_gain.setRange(-30.0, 30.0); self._spn_gain.setDecimals(1)
self._spn_gain.setSingleStep(0.5); self._spn_gain.setSuffix(" dB")
self._spn_gain.setToolTip("Gain applied to the extracted audio (0 = unchanged)")
```
Then **remove** these two lines (currently 4462-4463) so the controls no longer
sit in the transport row:
```python
transport_row.addWidget(self._spn_audio_len)
transport_row.addWidget(self._btn_extract_audio)
```
(Leave the `transport_row.addSpacing(12)` at 4440 if it now orphans a trailing
spacer — check the row visually in Task 8; drop the spacer if it looks odd.)
**Step 4: Run to verify pass**
Run: `pytest tests/test_ui_structure.py::test_audio_edit_widgets_exist -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: audio format + fade/normalize/gain widgets; free them from the transport row"
```
---
## Task 4: Rebuild `_build_audio_tab` as a `QToolBox` (Extract & Edit / Scan · Classify)
Replace the temporary scan grid with a two-page `QToolBox`. Page 1 places the
extract + edit widgets; Page 2 holds the (moved) scan grid.
**Files:**
- Modify: `main.py` `_build_audio_tab` (formerly `_build_scan_tab`, ~4745)
- Test: `tests/test_ui_structure.py`
**Step 1: Write the failing test**
```python
def test_audio_toolbox_has_two_panes(win):
from PyQt6.QtWidgets import QToolBox
tb = win._tab_audio.findChild(QToolBox)
assert tb is not None
titles = [tb.itemText(i) for i in range(tb.count())]
assert titles[0].startswith("Extract")
assert any("Scan" in t or "Classify" in t for t in titles)
# Extract controls now live under the Audio tab, not the transport row.
assert win._btn_extract_audio.parent() is not None
# Scan controls remain reachable.
assert win._btn_scan.isEnabled() in (True, False)
```
**Step 2: Run to verify it fails**
Run: `pytest tests/test_ui_structure.py::test_audio_toolbox_has_two_panes -v`
Expected: FAIL (no `QToolBox` under `_tab_audio`).
**Step 3: Implement**
Rewrite `_build_audio_tab` (keep the existing scan grid content — the `model_row`
`threshold` widgets — verbatim, just parented to the new `scan_page`):
```python
def _build_audio_tab(self) -> None:
outer = QVBoxLayout(self._tab_audio)
outer.setContentsMargins(0, 0, 0, 0)
tb = QToolBox()
# ── Page 1: Extract & Edit ───────────────────────────────
extract_page = QWidget()
g = QGridLayout(extract_page)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
g.addWidget(QLabel("Length:"), 0, 0)
g.addWidget(self._spn_audio_len, 0, 1)
g.addWidget(QLabel("Format:"), 0, 2)
g.addWidget(self._cmb_audio_fmt, 0, 3)
g.addWidget(self._group_sep(), 1, 0, 1, 4)
g.addWidget(QLabel("Fade in:"), 2, 0); g.addWidget(self._spn_fade_in, 2, 1)
g.addWidget(QLabel("Fade out:"), 2, 2); g.addWidget(self._spn_fade_out, 2, 3)
g.addWidget(self._chk_normalize, 3, 0, 1, 2)
g.addWidget(QLabel("Gain:"), 3, 2); g.addWidget(self._spn_gain, 3, 3)
# Waveform strip + audition land here in Tasks 6-7 (placeholder row 4).
g.addWidget(self._btn_extract_audio, 5, 0, 1, 4)
tb.addItem(extract_page, "Extract & Edit")
# ── Page 2: Scan / Classify (moved verbatim) ─────────────
scan_page = QWidget()
sg = QGridLayout(scan_page)
sg.setContentsMargins(8, 6, 8, 6); sg.setHorizontalSpacing(8); sg.setVerticalSpacing(6)
model_row = QHBoxLayout()
model_row.addWidget(self._cmb_scan_model, 1); model_row.addWidget(self._btn_model_history)
sg.addWidget(QLabel("Model:"), 0, 0); sg.addLayout(model_row, 0, 1, 1, 3)
sg.addWidget(self._group_sep(), 1, 0, 1, 4)
sg.addWidget(self._btn_scan, 2, 0); sg.addWidget(self._btn_auto_export, 2, 1)
sg.addWidget(self._btn_speech, 2, 2); sg.addWidget(self._btn_scan_mode, 2, 3)
sg.addWidget(self._group_sep(), 3, 0, 1, 4)
sg.addWidget(self._spn_auto_fuse, 4, 0); sg.addWidget(self._sld_threshold, 4, 1)
sg.setColumnStretch(3, 1)
tb.addItem(scan_page, "Scan / Classify")
outer.addWidget(tb)
```
> Confirm `QVBoxLayout` and `QLabel` are imported (they are — used throughout).
> If `QVBoxLayout` is missing from the import block, add it.
**Step 4: Run to verify pass**
Run: `pytest tests/test_ui_structure.py -v`
Expected: PASS. Update the existing `test_extract_audio_controls_exist` if it
assumed the transport row — it only checks widget types/attrs, so it stays green.
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: Audio tab QToolBox — Extract & Edit + Scan/Classify panes"
```
---
## Task 5: Wire format + edit params into `_on_extract_audio`
Extraction now honors the format picker (extension + save filter) and the edit
controls (fade/normalize/gain via `audio_edit_filters`; trim is deferred to the
optional trim spinboxes — see note). Keep the existing no-clamp behavior and
truncation reporting.
**Files:**
- Modify: `main.py` `_on_extract_audio` (6403-6457) + import (`audio_edit_filters` at line 38)
- Test: covered by `tests/test_utils.py` (command-level, Task 1) + manual (Task 8). Add one behavior test below.
**Step 1: Write the failing test** (in `tests/test_ui_structure.py`)
```python
def test_extract_uses_selected_format_and_edits(win, monkeypatch, tmp_path):
import core.ffmpeg as fx
win._file_path = "/x/video.mp4"
win._cursor = 5.0
win._spn_audio_len.setValue(4.0)
# pick MP3
win._cmb_audio_fmt.setCurrentIndex(win._cmb_audio_fmt.findData(".mp3"))
win._spn_fade_in.setValue(0.5)
captured = {}
def fake_cmd(inp, start, dur, out, filters=None):
captured.update(out=out, filters=filters or [])
return ["true"]
monkeypatch.setattr(win_module, "build_audio_clip_command", fake_cmd) # module alias
# short-circuit the save dialog to a temp mp3 and subprocess to success
monkeypatch.setattr(win_module.QFileDialog, "getSaveFileName",
staticmethod(lambda *a, **k: (str(tmp_path / "c.mp3"), "")))
monkeypatch.setattr(win_module.subprocess, "run",
lambda *a, **k: type("P", (), {"returncode": 0, "stderr": ""})())
monkeypatch.setattr(win_module.os.path, "exists", lambda p: True)
win._on_extract_audio()
assert captured["out"].endswith(".mp3")
assert any("afade=t=in" in f for f in captured["filters"])
```
> `win_module` = the imported `main` module in the test file; adjust to the
> fixture's existing import alias. If patching `os.path.exists` globally is too
> broad, patch `main.os.path.exists` only within the call or assert on
> `captured` before the exists check by making `fake_cmd` raise a sentinel.
**Step 2: Run to verify it fails**
Run: `pytest tests/test_ui_structure.py::test_extract_uses_selected_format_and_edits -v`
Expected: FAIL (format/edits not yet honored).
**Step 3: Implement**
In `_on_extract_audio`, replace the default-name/extension + command build:
```python
start = self._cursor
dur = self._spn_audio_len.value()
ext = self._cmb_audio_fmt.currentData() or ".wav"
fmt_label = self._cmb_audio_fmt.currentText()
stem = os.path.splitext(os.path.basename(self._file_path))[0]
default_name = f"{stem}_{start:.2f}-{start + dur:.2f}s{ext}"
default_dir = (self._settings.value("audio_extract_dir", "")
or self._tab_export_folder()
or os.path.dirname(self._file_path))
# Put the chosen format first in the filter list.
filters = (f"{fmt_label} (*{ext});;WAV (*.wav);;MP3 (*.mp3);;"
"FLAC (*.flac);;All files (*)")
path, _sel = QFileDialog.getSaveFileName(
self, "Save audio clip", os.path.join(default_dir, default_name), filters)
if not path:
return
if not os.path.splitext(path)[1]:
path += ext
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
edit = audio_edit_filters(
duration=dur,
fade_in=self._spn_fade_in.value(),
fade_out=self._spn_fade_out.value(),
normalize=self._chk_normalize.isChecked(),
gain_db=self._spn_gain.value())
cmd = build_audio_clip_command(self._file_path, start, dur, path,
filters=edit or None)
```
Add `audio_edit_filters` to the ffmpeg import at line 38:
```python
from core.ffmpeg import (build_audio_clip_command, audio_edit_filters,
probe_duration, ...) # keep existing names
```
**Step 4: Run to verify pass**
Run: `pytest tests/test_ui_structure.py -v && pytest tests/test_utils.py -k audio -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: extract honors format picker + fade/normalize/gain edits"
```
---
## Task 6: Read-only waveform strip
A compact widget that paints peak envelopes of the current region, refreshed on
cursor/length change. Decoding reuses `core/audio_scan._load_audio_ffmpeg`
(already returns mono float samples). Keep it dependency-free (QPainter).
**Files:**
- Create: `AudioWaveform(QWidget)` class in `main.py` (near `TimelineWidget`, ~line 1857) OR a small `core/waveform.py` for the peak-reduction (testable) + a thin painter in `main.py`.
- Modify: `_build_audio_tab` (add to row 4 of the extract grid), `_update_audio_region` (6394) to refresh peaks.
- Test: `tests/test_utils.py` for the pure peak-reduction; UI smoke test for the widget.
**Step 1: Write the failing test** (pure reduction in `core/waveform.py`)
```python
def test_peaks_downsamples_to_bucket_count():
from core.waveform import peaks
import numpy as np
samples = np.sin(np.linspace(0, 100, 10000)).astype("float32")
p = peaks(samples, buckets=64)
assert len(p) == 64
assert all(0.0 <= v <= 1.0 for v in p)
```
**Step 2: Run to verify it fails**`pytest tests/test_utils.py::test_peaks_downsamples_to_bucket_count -v` → FAIL (no module).
**Step 3: Implement** `core/waveform.py`:
```python
import numpy as np
def peaks(samples, buckets: int = 128) -> list[float]:
"""Reduce a 1-D sample array to *buckets* normalized peak magnitudes."""
if samples is None or len(samples) == 0:
return [0.0] * buckets
a = np.abs(np.asarray(samples, dtype="float32"))
idx = np.linspace(0, len(a), buckets + 1).astype(int)
out = [float(a[idx[i]:idx[i + 1]].max()) if idx[i + 1] > idx[i] else 0.0
for i in range(buckets)]
m = max(out) or 1.0
return [v / m for v in out]
```
Then add `AudioWaveform(QWidget)` in `main.py`: stores `self._peaks: list[float]`,
`set_peaks(p)` calls `update()`, `paintEvent` draws vertical bars centered on the
mid-line (teal, matching the timeline band `QColor(0,220,190)`). Fixed height ~48.
Place it at extract-grid row 4 spanning 4 columns. In `_update_audio_region`,
after computing `start`, decode the `[start, start+len]` slice off-thread (reuse
the scan prefetch pattern) or lazily on a "↻" button to avoid blocking the UI —
**decode must not run on the UI thread for long clips**; gate it behind a manual
refresh button in v1 (simplest, no threading).
**Step 4: Run to verify pass**`pytest tests/test_utils.py -k peaks -v` → PASS.
**Step 5: Commit**
```bash
git add core/waveform.py main.py tests/test_utils.py
git commit -m "feat: read-only waveform strip for the Audio tab"
```
---
## Task 7: Play / Stop audition
Audition the current region without disturbing the mpv video player. Simplest
robust approach: extract the region to a temp file via the existing command and
play it with `QMediaPlayer` + `QAudioOutput` (PyQt6.QtMultimedia), or shell out
to `ffplay`. Keep it a toggle button `_btn_audio_play`.
**Files:**
- Modify: `main.py` (`_btn_audio_play` construction in Task 3 block; handler `_on_audio_audition`; place button in `_build_audio_tab`).
- Test: UI smoke test that the button exists and toggling with no file is a no-op.
**Step 1: Write the failing test**
```python
def test_audition_button_exists_and_safe_without_file(win):
from PyQt6.QtWidgets import QPushButton
assert isinstance(win._btn_audio_play, QPushButton)
win._file_path = ""
win._on_audio_audition() # must not raise
```
**Step 2: Run** → FAIL (no `_btn_audio_play`).
**Step 3: Implement** — construct `_btn_audio_play` (checkable, "▶ Play") in the
Task 3 widget block; add to the extract grid next to the waveform; handler
renders the current region (respecting edits) to a temp file under the scratch
dir and plays via `QMediaPlayer`; toggling off stops. Guard on `self._file_path`.
Decide `QMediaPlayer` vs `ffplay` during implementation; prefer `QMediaPlayer`
(no extra process, stops cleanly). If QtMultimedia is unavailable in the frozen
build, fall back to `ffplay -autoexit -nodisp`.
**Step 4: Run** → PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: play/stop audition of the current audio region"
```
---
## Task 8: Full test pass, visual check, docs
**Step 1:** Run the whole suite:
```bash
pytest tests/ -v
```
Expected: all green. Fix any test that assumed the old transport-row placement.
**Step 2:** Launch the app (use the `/run` skill or `./8cut.sh`), load a video,
and verify by observation:
- Audio tab present as 3rd deck tab; `QToolBox` opens on *Extract & Edit*.
- Length + Format + edit controls present; transport row no longer shows them.
- Teal timeline band still tracks cursor + length.
- Extract with MP3 + fade-in produces an mp3 that fades in (spot-check with a
player); status line reports the saved length.
- Waveform refresh draws bars; Play auditions; Scan/Classify pane still scans.
**Step 3:** Update `README.md` + changelog: note the Audio tab replaces the Scan
tab and adds format + fade/normalize/gain + audition. Mention Phase 2 (audio
dataset) is planned.
**Step 4: Commit**
```bash
git add README.md
git commit -m "docs: Audio workspace tab (Phase 1) — changelog + README"
```
---
## Deferred to Phase 2 (separate plan)
- Audio dataset pane (browser + label, add-from-extraction, in-tab Stats /
Hard-negatives / Train). **Blocked on the merged-vs-separate decision** in the
design doc (recommended: merged — write labeled `(source_video, start, end)`
time-ranges into the existing per-profile training DB).
## Risks / watch-outs
- **`loudnorm` is single-pass here** — fine for audition/dataset clips; if you
need broadcast-accurate normalization, that's a two-pass follow-up.
- **Waveform/audition decode off the UI thread** — v1 gates waveform behind a
manual refresh and audition behind a temp render; do not decode long clips
synchronously on the UI thread.
- **Deck pin persistence** — old `"scan"` pin key is silently dropped; verify the
side-by-side (pin two panels) still works with the Audio panel.
- **`test_extract_uses_selected_format_and_edits`** monkeypatch details depend on
how `main` imports `build_audio_clip_command`/`QFileDialog`/`subprocess`;
adapt the patch targets to the actual module namespace.
@@ -0,0 +1,677 @@
# Audio Timeline Picker And Heal Cut 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:** Make the existing audio timeline band practical for picking regions and add a one-click Heal Cut editor workflow for removing short artifacts and rejoining the audio smoothly.
**Architecture:** Keep the current single-lane `TimelineWidget` as the source-region picker and keep `MainWindow` as the synchronization owner between timeline, cursor, length spinbox, and waveform. Add a pure `core.ffmpeg.build_audio_heal_delete_command()` command builder, then wire it into `AudioEditorDialog` as the primary destructive operation using the existing rendered-version undo/redo stack.
**Tech Stack:** Python 3, PyQt6, ffmpeg filtergraphs, pytest. Run `tests/test_ui_structure.py` and `tests/test_utils.py` in separate processes with `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen`.
---
## Baseline
Run before implementation:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -q
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -q
```
Expected baseline:
- `tests/test_ui_structure.py`: 70 passed.
- `tests/test_utils.py`: 90 passed, 3 known pre-existing failures:
- `test_ffmpeg_command_no_resize`
- `test_db_get_markers_returns_sorted`
- `test_audio_extract_timing`
## File Structure
- `core/ffmpeg.py`: add pure Heal Cut command builder and small helper for automatic fade duration.
- `tests/test_utils.py`: add pure tests for Heal Cut command generation.
- `main.py`: import builder, improve `TimelineWidget` audio interactions, add editor Heal Cut UI, join preview range, loop playback, and Save to Library.
- `tests/test_ui_structure.py`: add focused Qt structure/behavior tests for timeline picking and editor integration.
## Task 1: Pure Heal Cut ffmpeg Builder
**Files:**
- Modify: `core/ffmpeg.py`
- Modify: `tests/test_utils.py`
- [ ] **Step 1: Write failing tests**
Append these tests after `test_audio_delete_empty_head` in `tests/test_utils.py`:
```python
def test_audio_heal_delete_command_crossfades_join():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 2.0, 4.0, "/o/o.wav", crossfade=0.1)
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=2.0" in fc
assert "atrim=start=4.0" in fc
assert "acrossfade=d=0.1:c1=qsin:c2=qsin[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd
assert cmd[-1] == "/o/o.wav"
def test_audio_heal_delete_command_auto_crossfade_clamped():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 10.0, 12.0, "/o/o.mp3")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.25:c1=qsin:c2=qsin[out]" in fc
assert "libmp3lame" in cmd
def test_audio_heal_delete_command_near_start_shortens_crossfade():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 0.03, 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.03:c1=qsin:c2=qsin[out]" in fc
def test_audio_heal_delete_command_rejects_invalid_region():
import pytest
from core.ffmpeg import build_audio_heal_delete_command
with pytest.raises(ValueError):
build_audio_heal_delete_command("/in.wav", 3.0, 3.0, "/o/o.wav")
```
- [ ] **Step 2: Verify RED**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -k "audio_heal_delete" -q
```
Expected: collection/import failure or test failure because `build_audio_heal_delete_command` does not exist.
- [ ] **Step 3: Implement minimal builder**
In `core/ffmpeg.py`, add this helper and command builder after `build_audio_delete_command`:
```python
def _auto_heal_crossfade(start: float, end: float,
requested: float | None = None) -> float:
if end <= start:
raise ValueError("heal delete end must be greater than start")
if requested is not None:
fade = max(0.0, float(requested))
else:
fade = min(0.25, max(0.04, (end - start) * 0.25))
# Without knowing total duration, clamp only to available pre-roll.
fade = min(fade, max(0.0, float(start)))
return round(fade, 3)
def build_audio_heal_delete_command(input_path: str, start: float, end: float,
out_path: str,
crossfade: float | None = None) -> list[str]:
"""Remove [start, end] and heal the join with a short equal-power crossfade."""
if end <= start:
raise ValueError("heal delete end must be greater than start")
s, e = round(start, 3), round(end, 3)
xf = _auto_heal_crossfade(s, e, crossfade)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
if xf <= 0:
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]concat=n=2:v=0:a=1[out]")
else:
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]acrossfade=d={xf}:c1=qsin:c2=qsin[out]")
return [_bin("ffmpeg"), "-y", "-i", input_path,
"-filter_complex", fc, "-map", "[out]", *codec, out_path]
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -k "audio_heal_delete or audio_delete or audio_silence or audio_reverse" -q
```
Expected: all selected tests pass.
- [ ] **Step 5: Commit**
```bash
git add core/ffmpeg.py tests/test_utils.py
git commit -m "feat: add heal cut ffmpeg command"
```
## Task 2: Timeline Audio Band Interaction Upgrade
**Files:**
- Modify: `main.py`
- Modify: `tests/test_ui_structure.py`
- [ ] **Step 1: Write failing tests**
Add these tests after `test_timeline_audio_band_resize_right` in `tests/test_ui_structure.py`:
```python
def test_timeline_audio_click_outside_moves_region_start(win):
tl = win._timeline
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
got = []
tl.audio_region_changed.connect(lambda s, e: got.append((s, e)))
tl._audio_begin_drag_at_x(tl._time_to_x(12.0))
tl._audio_end_drag()
assert got[-1] == tl._audio_region
s, e = tl._audio_region
assert abs(s - 12.0) < 0.1
assert abs((e - s) - 4.0) < 0.1
def test_timeline_audio_drag_empty_creates_region(win):
tl = win._timeline
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
tl._audio_begin_drag_at_x(tl._time_to_x(12.0))
tl._audio_drag_to_x(tl._time_to_x(15.0))
tl._audio_end_drag()
s, e = tl._audio_region
assert abs(s - 12.0) < 0.1
assert abs(e - 15.0) < 0.1
def test_timeline_audio_hover_cursor_state(win):
tl = win._timeline
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
assert tl._audio_hit_at_x(tl._time_to_x(4.0)) == "left"
assert tl._audio_hit_at_x(tl._time_to_x(8.0)) == "right"
assert tl._audio_hit_at_x(tl._time_to_x(6.0)) == "move"
assert tl._audio_hit_at_x(tl._time_to_x(12.0)) == "create"
```
- [ ] **Step 2: Verify RED**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "timeline_audio" -q
```
Expected: failures because `_audio_hit_at_x` does not exist and empty-space click/drag does not create/move the region.
- [ ] **Step 3: Implement minimal timeline changes**
In `TimelineWidget.__init__`, change `_AUDIO_EDGE_PX = 6` to `_AUDIO_EDGE_PX = 10`.
Add a helper near `_audio_begin_drag_at_x`:
```python
def _audio_hit_at_x(self, x: float) -> str | None:
if not self._audio_mode or self._audio_region is None:
return None
a0, a1 = self._audio_region
ax0 = self._time_to_x(a0)
ax1 = self._time_to_x(a1)
if abs(x - ax0) <= self._AUDIO_EDGE_PX:
return "left"
if abs(x - ax1) <= self._AUDIO_EDGE_PX:
return "right"
if ax0 < x < ax1:
return "move"
return "create"
```
Update `_audio_begin_drag_at_x` so `"create"` preserves current width until the pointer moves:
```python
def _audio_begin_drag_at_x(self, x: float) -> None:
if not self._audio_mode or self._audio_region is None:
return
hit = self._audio_hit_at_x(x)
a0, a1 = self._audio_region
t = max(0.0, min(self._pos_to_time(int(x)), self._duration))
self._audio_drag = hit
if hit == "move":
self._audio_drag_anchor = t - a0
elif hit == "create":
width = max(self._AUDIO_MIN_W, a1 - a0)
a0 = max(0.0, min(t, max(0.0, self._duration - width)))
self._audio_region = (a0, a0 + width)
self.update()
if self._audio_drag is not None:
self._audio_drag_orig = self._audio_region
```
Update `_audio_drag_to_x` with a create branch:
```python
elif self._audio_drag == "create":
anchor = self._audio_drag_orig[0] if self._audio_drag_orig else a0
a0 = min(anchor, t)
a1 = max(anchor + self._AUDIO_MIN_W, max(anchor, t))
```
Update `mouseMoveEvent` hover cursor logic before marker hover:
```python
if self._audio_mode and self._audio_region is not None:
hit = self._audio_hit_at_x(x)
if hit in ("left", "right"):
self.setCursor(Qt.CursorShape.SizeHorCursor)
elif hit == "move":
self.setCursor(Qt.CursorShape.OpenHandCursor)
elif hit == "create":
self.setCursor(Qt.CursorShape.CrossCursor)
return
```
In `paintEvent`, draw handle rectangles in Audio mode after the audio band edge lines:
```python
p.fillRect(ax1 - 4, rh, 8, th, QColor(0, 220, 190, 120))
p.fillRect(ax2 - 4, rh, 8, th, QColor(0, 220, 190, 120))
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "timeline_audio" -q
```
Expected: all selected timeline audio tests pass.
- [ ] **Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: improve audio region timeline picking"
```
## Task 3: Editor Heal Cut UI And Undo Integration
**Files:**
- Modify: `main.py`
- Modify: `tests/test_ui_structure.py`
- [ ] **Step 1: Write failing tests**
Update `test_audio_editor_dialog_scaffold` so the button list includes `_btn_heal_cut`:
```python
for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse",
"_btn_trim", "_btn_undo", "_btn_redo", "_btn_save_as"):
assert isinstance(getattr(dlg, name), QPushButton)
```
Add this test after `test_editor_delete_builds_command`:
```python
def test_editor_heal_cut_builds_command(win, tmp_path, monkeypatch):
import main as m, pytest
src = tmp_path / "v0.wav"
src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 6.0
dlg._wave.set_view(0.0, 6.0)
dlg._wave.set_selection(2.0, 3.0)
seen = {}
class _Stop(Exception):
pass
def fake(inp, s, e, out):
seen.update(inp=inp, s=s, e=e, out=out)
raise _Stop
monkeypatch.setattr(m, "build_audio_heal_delete_command", fake)
with pytest.raises(_Stop):
dlg._on_heal_cut()
assert seen["inp"] == str(src)
assert seen["s"] == 2.0 and seen["e"] == 3.0
assert seen["out"].endswith(".wav")
```
- [ ] **Step 2: Verify RED**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_heal_cut" -q
```
Expected: failures because `_btn_heal_cut`, `_on_heal_cut`, and the import do not exist.
- [ ] **Step 3: Implement minimal editor wiring**
In `main.py`, update the `from core.ffmpeg import` block so this line:
```python
build_audio_delete_command, build_audio_silence_command,
build_audio_reverse_command,
```
becomes:
```python
build_audio_delete_command, build_audio_silence_command,
build_audio_reverse_command, build_audio_heal_delete_command,
```
In `AudioEditorDialog.__init__`, create the button before `_btn_delete`:
```python
self._btn_heal_cut = QPushButton("Heal Cut")
```
Wire it in the button/slot tuple:
```python
(self._btn_heal_cut, self._on_heal_cut),
```
Add it first in the ops row:
```python
ops.addWidget(self._btn_heal_cut)
for b in (self._btn_delete, self._btn_silence, self._btn_reverse,
self._btn_trim):
ops.addWidget(b)
```
Add the handler next to `_on_delete`:
```python
def _on_heal_cut(self):
self._apply_op(build_audio_heal_delete_command, whole_clip_ok=False,
after_success=self._prepare_join_preview)
```
Update `_apply_op` signature and its successful-render block. The function header becomes:
```python
def _apply_op(self, build_fn, whole_clip_ok: bool = True,
after_success=None) -> None:
```
The successful-render block becomes:
```python
if (proc is not None and proc.returncode == 0
and os.path.exists(tmp) and os.path.getsize(tmp) > 0):
del self._versions[self._ver_idx + 1:]
self._versions.append(tmp)
self._ver_idx += 1
self._temps.add(tmp)
self._reload()
if after_success is not None:
after_success(s)
self._set_status("")
```
Add a no-op preview method for this task:
```python
def _prepare_join_preview(self, seam_t: float) -> None:
self._join_preview = (max(0.0, seam_t - 1.0), seam_t + 1.0)
```
Initialize before `_reload()`:
```python
self._join_preview: tuple[float, float] | None = None
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_heal_cut or editor_delete" -q
```
Expected: selected editor tests pass.
- [ ] **Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: add heal cut editor action"
```
## Task 4: Join Loop Preview And Save To Library
**Files:**
- Modify: `main.py`
- Modify: `tests/test_ui_structure.py`
- [ ] **Step 1: Write failing tests**
Add this test after `test_editor_play_stop_safe`:
```python
def test_editor_loop_join_plays_preview_range(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"
src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._join_preview = (1.0, 3.0)
seen = {}
monkeypatch.setattr(dlg, "_play_current", lambda start=None, end=None: seen.update(start=start, end=end))
dlg._on_loop_join()
assert seen == {"start": 1.0, "end": 3.0}
```
Add this test after the library tests:
```python
def test_editor_save_to_library_adds_current_version(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"
src.write_bytes(b"")
out_dir = tmp_path / "library"
win._settings.setValue("audio_library_dir", str(out_dir))
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 2.0
saved = []
monkeypatch.setattr(m, "build_audio_clip_command",
lambda inp, start, dur, out: ["ffmpeg", out])
def fake_run(cmd, capture_output=True, timeout=300):
target = cmd[-1]
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "wb") as f:
f.write(b"x")
class Proc:
returncode = 0
return Proc()
monkeypatch.setattr(m.subprocess, "run", fake_run)
win._scan_panel._library.add_clip = lambda p: saved.append(p)
dlg._on_save_to_library()
assert saved
assert saved[0].startswith(str(out_dir))
```
- [ ] **Step 2: Verify RED**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "loop_join or save_to_library" -q
```
Expected: failures because `_on_loop_join`, `_play_current`, `_btn_loop_join`, `_btn_save_library`, and `_on_save_to_library` do not exist.
- [ ] **Step 3: Implement playback helper and save button**
In `AudioEditorDialog.__init__`, add:
```python
self._btn_loop_join = QPushButton("Loop Join")
self._btn_save_library = QPushButton("Save to Library")
```
Wire buttons:
```python
(self._btn_loop_join, self._on_loop_join),
(self._btn_save_library, self._on_save_to_library),
```
Add `_btn_loop_join` beside play and `_btn_save_library` before Save As.
Replace `_on_play` playback start with a helper:
```python
def _on_play(self, checked: bool) -> None:
if not checked:
self._stop_play()
return
self._play_current()
def _play_current(self, start: float | None = None,
end: float | None = None) -> None:
from PyQt6.QtCore import QProcess
self._stop_play()
self._play_proc = QProcess(self)
self._play_proc.finished.connect(self._on_play_finished)
self._play_proc.errorOccurred.connect(self._on_play_error)
args = ["-autoexit", "-nodisp", "-loglevel", "error"]
if start is not None:
args += ["-ss", str(start)]
if end is not None and start is not None and end > start:
args += ["-t", str(end - start)]
args.append(self._current())
self._play_proc.start(_bin("ffplay"), args)
self._btn_play.setText("■ Stop")
if not self._btn_play.isChecked():
self._btn_play.blockSignals(True)
self._btn_play.setChecked(True)
self._btn_play.blockSignals(False)
```
Add loop handler:
```python
def _on_loop_join(self) -> None:
if not self._join_preview:
self._set_status("No healed join to preview")
return
self._play_current(*self._join_preview)
```
Add save-to-library handler:
```python
def _on_save_to_library(self) -> None:
parent = self.parent()
settings = getattr(parent, "_settings", None)
scan_panel = getattr(parent, "_scan_panel", None)
if settings is None or scan_panel is None:
self._set_status("Library unavailable")
return
base = settings.value("audio_library_dir", "")
if not base:
base = os.path.join(str(Path.home()), "8cut_audio_library")
settings.setValue("audio_library_dir", base)
try:
os.makedirs(base, exist_ok=True)
except OSError:
self._set_status("Could not create library folder")
return
stem = os.path.splitext(os.path.basename(self._current()))[0] or "clip"
out = os.path.join(base, f"{stem}_edited.wav")
i = 1
while os.path.exists(out):
out = os.path.join(base, f"{stem}_edited_{i}.wav")
i += 1
dur = probe_duration(self._current()) or self._clip_dur or 0.0
cmd = build_audio_clip_command(self._current(), 0.0, dur, out)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
proc = subprocess.run(cmd, capture_output=True, timeout=300)
except Exception:
proc = None
finally:
QApplication.restoreOverrideCursor()
if proc is not None and proc.returncode == 0 and os.path.exists(out):
scan_panel._library.add_clip(out)
self._last_saved = out
self._set_status(f"Saved to library: {os.path.basename(out)}")
else:
self._set_status("Save to library failed")
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_play_stop_safe or loop_join or save_to_library" -q
```
Expected: selected tests pass.
- [ ] **Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: preview healed joins and save editor clips to library"
```
## Task 5: Final Verification
**Files:**
- Verify only; no file changes expected.
- [ ] **Step 1: Run full UI tests**
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -q
```
Expected: all tests pass.
- [ ] **Step 2: Run utility tests**
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -q
```
Expected: new Heal Cut tests pass; the same three baseline failures remain unless unrelated baseline fixes were made:
- `test_ffmpeg_command_no_resize`
- `test_db_get_markers_returns_sorted`
- `test_audio_extract_timing`
- [ ] **Step 3: Import smoke**
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -c "import main; print('OK')"
```
Expected: prints `OK`.
- [ ] **Step 4: Review git diff**
```bash
git status --short
git diff --stat HEAD~4..HEAD
```
Expected: commits touch only `core/ffmpeg.py`, `main.py`, `tests/test_utils.py`, and `tests/test_ui_structure.py`, plus this plan commit if it was committed separately.
@@ -0,0 +1,204 @@
# Audio Timeline Picker And Heal Cut Editor Design
## Goal
Make audio editing practical by splitting the workflow across two clear surfaces:
- The main timeline is the fast, precise audio region picker.
- The clip editor is the finishing surface for one selected clip, centered on removing short noise artifacts and rejoining the audio cleanly.
This pass focuses on the common workflow: select a bad 1-2 second artifact, remove it, heal the join, audition the seam, then save the result.
## Current Context
Recent audio work made the Audio tab first-class and added an audio region band, waveform selection, a clip library, merge support, and a destructive clip editor. The existing implementation is functionally broad, but the interaction model is still weak:
- The timeline audio band has small edge hit targets and poor discoverability.
- The timeline has a playhead, but in Audio mode the relationship between the yellow cursor and teal audio region is not clear enough.
- The editor supports Delete, Silence, Reverse, Trim, undo/redo, playback, and Save As, but it lacks a purpose-built artifact-removal operation.
- The merge/crossfade pane is useful, but the immediate need is better one-clip cleanup rather than multi-clip assembly.
## Approved Scope
In scope:
- Upgrade the existing single-lane timeline audio band rather than adding a separate audio lane.
- Make the teal audio band easy to move, resize, and create with the mouse.
- Keep the yellow playhead/cursor visible and visually distinct in Audio mode.
- Keep the timeline band, length spinbox, and waveform selection synchronized.
- Add a primary `Heal Cut` operation to `AudioEditorDialog`.
- Keep existing editor operations as secondary tools: Hard Delete, Silence, Reverse, Trim.
- Add join-focused loop audition after Heal Cut.
- Add clearer save actions: Save to Library and Save As.
- Store Heal Cut outputs in the existing temp-version undo/redo stack.
- Add a pure ffmpeg command builder in `core/ffmpeg.py` so heal behavior is testable outside Qt.
Out of scope for this pass:
- A separate timeline audio lane.
- A full multitrack editor.
- Saved editor projects.
- Spectral repair or ML audio inpainting.
- Major changes to the merge/crossfade pane.
- Database or schema changes.
## Timeline Picker Design
`TimelineWidget` remains the audio picking surface in Audio mode.
Interaction changes:
- Increase the audio band edge hit target and draw larger visible handles.
- Change the cursor on hover:
- left/right edge: horizontal resize
- inside band: move
- outside band: set/create region
- Drag an edge to resize the region.
- Drag inside the region to move it while preserving length.
- Click outside the region to set the audio region start at that time while preserving length.
- Drag from empty timeline space to create a new region.
- Emit `audio_region_changed(start, end)` on completed user edits, as today.
Visual changes:
- Keep the teal band as the audio region.
- Use brighter/solid handle styling in Audio mode.
- Keep the yellow cursor/playhead visible at the current start/play position.
- Avoid showing the normal blue export clip span in Audio mode.
- Preserve scan-region edge behavior and normal non-audio export behavior outside Audio mode.
Sync behavior:
- `MainWindow` remains the owner of synchronization between:
- `TimelineWidget._audio_region`
- `_cursor`
- `_spn_audio_len`
- `AudioWaveform` selection
- A timeline audio edit updates `_cursor` and audio length.
- A length spinbox edit updates the timeline audio band and waveform selection.
- A waveform selection edit updates `_cursor`, the length spinbox, and the timeline audio band.
## Editor UI Design
`AudioEditorDialog` becomes a focused one-clip cleanup tool.
Layout:
- Large waveform area with selection handles.
- Primary action panel with `Heal Cut`.
- Secondary destructive tools:
- Hard Delete
- Silence
- Reverse
- Trim
- Playback controls:
- Play clip
- Play selection
- Loop Join
- History:
- Undo
- Redo
- Save:
- Save to Library
- Save As
Behavior:
- Heal Cut is the recommended/default artifact-removal action.
- Hard Delete remains available for cases where no smoothing is wanted.
- After a successful Heal Cut, the editor sets a join preview range and starts or enables looping around the healed seam.
- Save to Library writes the current edited version into the managed audio library folder and adds it to `AudioLibraryTab`.
- Save As keeps the existing file-dialog path.
## Heal Cut Behavior
Primary workflow:
1. User selects the artifact region in the editor waveform.
2. User clicks `Heal Cut`.
3. The editor renders a new temp version.
4. The selected artifact is removed.
5. The before/after audio is rejoined with an automatic short equal-power crossfade.
6. The editor reloads the new version, pushes it onto the undo stack, and prepares a loop preview around the seam.
Command builder:
Add this pure function in `core/ffmpeg.py`:
```python
def build_audio_heal_delete_command(
input_path: str,
start: float,
end: float,
out_path: str,
crossfade: float | None = None,
) -> list[str]:
```
Automatic crossfade:
- If `crossfade` is omitted, choose it automatically.
- Use a small practical range: 40-250 ms.
- Base the chosen duration on the selected artifact length and nearby available material.
- Clamp crossfade so it fits before `start` and after `end`.
- If no crossfade can fit, fall back to the shortest valid fade or hard delete and report that in editor status.
Implementation shape:
- Build the output from the kept region before the artifact and the kept region after the artifact.
- Overlap the join by the chosen crossfade duration.
- Use ffmpeg `acrossfade` with `qsin` for both sides (`c1=qsin:c2=qsin`), matching the existing curve list in the merge code.
- Keep output codec behavior aligned with the existing audio command helpers.
Zero-crossing:
- Zero-crossing edge adjustment is desirable, but should not block the first useful version.
- First pass should deliver automatic equal-power Heal Cut via ffmpeg.
- Treat sample-level zero-crossing adjustment as a deferred enhancement after the ffmpeg-based Heal Cut is working and tested.
## Error Handling
Heal Cut guardrails:
- If no selection exists, show `Select an artifact first`.
- If the selection is too short, show a status message and do not edit.
- If the selection would remove the whole clip, block it.
- If the selected region is too close to the start or end for the preferred crossfade, use a shorter valid fade.
- If ffmpeg fails, keep the current version unchanged and remove the temp output.
- If the output is empty or missing, treat it as failure.
Timeline guardrails:
- Audio-mode mouse behavior must not affect non-audio export mode.
- Audio-band interactions must not interfere with Shift-drag scan-region resizing.
- Re-entrant synchronization between timeline, waveform, and spinbox must stay guarded.
## Testing Plan
Unit tests in `tests/test_utils.py`:
- `build_audio_heal_delete_command` normal case uses a crossfade join.
- Explicit crossfade duration is respected.
- Automatic crossfade is clamped to a practical range.
- Near-start and near-end selections do not produce negative trim times.
- Whole-clip or invalid selections are rejected by the caller or produce a clear error path.
- Existing Delete/Silence/Reverse command tests continue to pass.
UI tests in `tests/test_ui_structure.py`:
- Timeline audio band exposes larger handle behavior through direct helper tests.
- Clicking/dragging empty audio-mode timeline space can create or reset an audio region.
- Timeline audio edit still syncs `_cursor` and `_spn_audio_len`.
- `AudioEditorDialog` has a primary Heal Cut button.
- Heal Cut invokes the new command builder and pushes a new undo version on success.
- Heal Cut failure leaves the current version unchanged.
- Save to Library adds the saved current version to the library.
Verification commands should keep the repo's existing isolation rule:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -v
```
Do not combine the Qt structure tests and utility tests in one process.
+1473 -52
View File
File diff suppressed because it is too large Load Diff
+738 -3
View File
@@ -1,3 +1,4 @@
import os
import pytest import pytest
# Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so # Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so
@@ -53,9 +54,9 @@ def test_workers_spinbox_in_export_tab(win):
assert win._spn_workers in win._tab_export.findChildren(QSpinBox) assert win._spn_workers in win._tab_export.findChildren(QSpinBox)
def test_scan_button_in_scan_tab(win): def test_scan_button_in_audio_tab(win):
from PyQt6.QtWidgets import QPushButton from PyQt6.QtWidgets import QPushButton
assert win._btn_scan in win._tab_scan.findChildren(QPushButton) assert win._btn_scan in win._tab_audio.findChildren(QPushButton)
def test_portrait_combo_in_crop_tab(win): def test_portrait_combo_in_crop_tab(win):
@@ -106,7 +107,7 @@ def test_side_by_side_menu_pins_third_panel(win):
# (there's no tab bar to right-click). Suppress the QSettings save via the # (there's no tab bar to right-click). Suppress the QSettings save via the
# _deck_loading guard so this doesn't leak into other windows. # _deck_loading guard so this doesn't leak into other windows.
win._tab_export._pinned = True win._tab_export._pinned = True
win._tab_scan._pinned = True win._tab_audio._pinned = True
win._refresh_deck_layout() win._refresh_deck_layout()
assert len(_split_columns(win)) == 2 assert len(_split_columns(win)) == 2
act = next(a for a, p in win._deck_pin_actions if p is win._tab_crop) act = next(a for a, p in win._deck_pin_actions if p is win._tab_crop)
@@ -271,3 +272,737 @@ def test_audio_region_tracks_cursor_and_length(win):
win._file_path = "" win._file_path = ""
win._update_audio_region() win._update_audio_region()
assert win._timeline._audio_region is None assert win._timeline._audio_region is None
def test_timeline_audio_mode_flag(win):
tl = win._timeline
tl.set_audio_mode(True)
assert tl._audio_mode is True
tl.set_audio_mode(False)
assert tl._audio_mode is False
def test_timeline_audio_band_drag_move(win):
tl = win._timeline
# The band is now wired to the window; fake a loaded file so the window's
# _update_audio_region keeps the region instead of clearing it on release.
win._file_path = "/x/v.mp4"
tl._duration = 20.0
tl._view_start = 0.0; tl._view_span = 20.0 # full view
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0) # [4,8]
got = []
tl.audio_region_changed.connect(lambda s, e: got.append((s, e)))
# grab the middle of the band and drag it +2s
mid_t = 6.0
tl._audio_begin_drag_at_x(tl._time_to_x(mid_t))
tl._audio_drag_to_x(tl._time_to_x(mid_t + 2.0))
tl._audio_end_drag()
assert got, "audio_region_changed should fire on release"
s, e = tl._audio_region
assert abs((e - s) - 4.0) < 0.1 and s > 4.0 # same width, moved right
tl.set_audio_mode(False)
def test_timeline_audio_band_resize_right(win):
tl = win._timeline
# Fake a loaded file so the window keeps the region on release (see above).
win._file_path = "/x/v.mp4"
tl._duration = 20.0; tl._view_start = 0.0; tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
tl._audio_begin_drag_at_x(tl._time_to_x(8.0)) # grab right edge
tl._audio_drag_to_x(tl._time_to_x(10.0)) # drag to 10
tl._audio_end_drag()
s, e = tl._audio_region
assert abs(s - 4.0) < 0.1 and abs(e - 10.0) < 0.2
tl.set_audio_mode(False)
def test_timeline_audio_click_outside_moves_region_start(win):
tl = win._timeline
win._file_path = "/x/v.mp4"
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
got = []
tl.audio_region_changed.connect(lambda s, e: got.append((s, e)))
tl._audio_begin_drag_at_x(tl._time_to_x(12.0))
tl._audio_end_drag()
assert got[-1] == tl._audio_region
s, e = tl._audio_region
assert abs(s - 12.0) < 0.1
assert abs((e - s) - 4.0) < 0.1
def test_timeline_audio_drag_empty_creates_region(win):
tl = win._timeline
win._file_path = "/x/v.mp4"
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
tl._audio_begin_drag_at_x(tl._time_to_x(12.0))
tl._audio_drag_to_x(tl._time_to_x(15.0))
tl._audio_end_drag()
s, e = tl._audio_region
assert abs(s - 12.0) < 0.1
assert abs(e - 15.0) < 0.1
def test_timeline_audio_hover_cursor_state(win):
tl = win._timeline
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
assert tl._audio_hit_at_x(tl._time_to_x(4.0)) == "left"
assert tl._audio_hit_at_x(tl._time_to_x(8.0)) == "right"
assert tl._audio_hit_at_x(tl._time_to_x(6.0)) == "move"
assert tl._audio_hit_at_x(tl._time_to_x(12.0)) == "create"
def test_timeline_audio_mode_follows_deck(win):
win._control_deck.setCurrentWidget(win._tab_audio)
assert win._timeline._audio_mode is True
win._control_deck.setCurrentWidget(win._tab_export)
assert win._timeline._audio_mode is False
def test_timeline_audio_region_edit_syncs_cursor_length(win):
win._file_path = "/x/v.mp4"
win._cursor = 2.0
win._spn_audio_len.setValue(3.0)
win._timeline.audio_region_changed.emit(5.0, 9.0) # user dragged the band to [5,9]
assert win._cursor == 5.0
assert abs(win._spn_audio_len.value() - 4.0) < 1e-6
def test_audio_deck_tab_exists(win):
# The old "Scan" deck tab is now "Audio".
assert hasattr(win, "_tab_audio")
assert win._tab_audio._deck_key == "audio"
assert win._tab_audio in win._deck_panels
assert not hasattr(win, "_tab_scan")
labels = [win._control_deck.tabText(i)
for i in range(win._control_deck.count())]
assert "Audio" in labels and "Scan" not in labels
def test_audio_edit_widgets_exist(win):
from PyQt6.QtWidgets import QComboBox, QCheckBox, QDoubleSpinBox
assert isinstance(win._cmb_audio_fmt, QComboBox)
assert win._cmb_audio_fmt.count() >= 3 # wav/mp3/flac at least
assert isinstance(win._spn_fade_in, QDoubleSpinBox)
assert isinstance(win._spn_fade_out, QDoubleSpinBox)
assert isinstance(win._chk_normalize, QCheckBox)
assert isinstance(win._spn_gain, QDoubleSpinBox)
assert win._cmb_audio_fmt.findData(".wav") >= 0
assert win._cmb_audio_fmt.findData(".mp3") >= 0
assert win._cmb_audio_fmt.findData(".flac") >= 0
def test_audio_toolbox_has_two_panes(win):
from PyQt6.QtWidgets import QToolBox, QPushButton
tb = win._tab_audio.findChild(QToolBox)
assert tb is not None
titles = [tb.itemText(i) for i in range(tb.count())]
assert titles[0].startswith("Extract")
assert any("Scan" in t or "Classify" in t for t in titles)
# Extract controls now live under the Audio tab.
assert win._btn_extract_audio in tb.findChildren(QPushButton)
# Scan controls remain reachable.
assert win._btn_scan in tb.findChildren(QPushButton)
def test_merge_pane_present(win):
from PyQt6.QtWidgets import QToolBox, QListWidget, QDoubleSpinBox
tb = win._tab_audio.findChild(QToolBox)
titles = [tb.itemText(i) for i in range(tb.count())]
assert "Merge" in titles
assert isinstance(win._merge_list, QListWidget)
assert isinstance(win._spn_crossfade, QDoubleSpinBox)
assert win._spn_crossfade.value() == 0.5
def test_extract_honors_format_and_edits(win, monkeypatch, tmp_path):
import main as m
import pytest
win._file_path = "/x/video.mp4"
win._cursor = 5.0
win._spn_audio_len.setValue(4.0)
win._cmb_audio_fmt.setCurrentIndex(win._cmb_audio_fmt.findData(".mp3"))
win._spn_fade_in.setValue(0.5)
win._chk_normalize.setChecked(True)
seen = {}
class _Stop(Exception):
pass
def fake_cmd(inp, start, dur, out, filters=None):
seen["out"] = out
seen["filters"] = filters or []
raise _Stop
monkeypatch.setattr(m, "build_audio_clip_command", fake_cmd)
dialog = {}
def fake_savedialog(parent, title, default_path, filt):
dialog["default"] = default_path
dialog["filter"] = filt
return (str(tmp_path / "clip.mp3"), "")
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
staticmethod(fake_savedialog))
with pytest.raises(_Stop):
win._on_extract_audio()
# Default filename used the picked format's extension, and that format leads the filter.
assert dialog["default"].endswith(".mp3")
assert dialog["filter"].startswith("MP3")
# Output path + edit filters flowed into the command builder.
assert seen["out"].endswith(".mp3")
assert any("afade=t=in" in f for f in seen["filters"])
assert "loudnorm" in seen["filters"]
def test_extract_no_edits_passes_no_filters(win, monkeypatch, tmp_path):
import main as m
import pytest
win._file_path = "/x/video.mp4"
win._cursor = 5.0
win._spn_audio_len.setValue(4.0)
# all edit controls at default
win._spn_fade_in.setValue(0.0)
win._spn_fade_out.setValue(0.0)
win._chk_normalize.setChecked(False)
win._spn_gain.setValue(0.0)
seen = {}
class _Stop(Exception):
pass
def fake_cmd(inp, start, dur, out, filters=None):
seen["filters"] = filters
raise _Stop
monkeypatch.setattr(m, "build_audio_clip_command", fake_cmd)
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
staticmethod(lambda *a, **k: (str(tmp_path / "c.wav"), "")))
with pytest.raises(_Stop):
win._on_extract_audio()
assert seen["filters"] is None
def test_add_selection_to_library(win, tmp_path, monkeypatch):
import main as m
win._file_path = "/vids/Chloe.mp4"
win._cursor = 7.0
win._spn_audio_len.setValue(3.0)
win._settings.setValue("audio_extract_dir", str(tmp_path))
win._settings.setValue("audio_library_dir", "") # force derive
seen = {}
def fake_cmd(inp, start, dur, out, filters=None):
seen["out"] = out; seen["start"] = start; seen["dur"] = dur; return ["true"]
monkeypatch.setattr(m, "build_audio_clip_command", fake_cmd)
monkeypatch.setattr(m.subprocess, "run",
lambda *a, **k: type("P", (), {"returncode": 0})())
monkeypatch.setattr(m.os.path, "exists", lambda p: True)
monkeypatch.setattr(m, "probe_duration", lambda p: 3.0)
lib = win._scan_panel._library
lib._key = "audio_library_addsel_test"
win._settings.setValue(lib._key, [])
lib._list.clear()
win._on_add_selection_to_library()
# rendered the current area with the managed-folder auto-name
assert seen["start"] == 7.0 and seen["dur"] == 3.0
assert os.path.join("audio_library", "Chloe_7.00-10.00s.wav") in seen["out"]
# and it was added to the library
assert seen["out"] in lib.clips()
def test_add_selection_to_library_no_file_safe(win):
win._file_path = ""
win._on_add_selection_to_library() # must not raise
def test_waveform_strip_present_and_safe(win):
from PyQt6.QtWidgets import QPushButton
assert win._wave is not None
assert isinstance(win._btn_wave_refresh, QPushButton)
# set_peaks stores + doesn't crash
win._wave.set_peaks([0.1, 0.5, 0.9])
assert list(win._wave._peaks) == [0.1, 0.5, 0.9]
win._wave.clear()
assert win._wave._peaks == []
# refresh with no file loaded is a safe no-op
win._file_path = ""
win._on_wave_refresh()
def test_waveform_view_and_selection(win):
w = win._wave
w.set_view(10.0, 4.0)
w.set_selection(11.0, 12.5)
assert w._view_start == 10.0 and w._view_dur == 4.0
assert w.selection() == (11.0, 12.5)
# selection clamps into the view window and keeps start < end
w.set_selection(9.0, 20.0)
s, e = w.selection()
assert s >= 10.0 and e <= 14.0 and s < e
w.set_peaks([0.1, 0.9, 0.3]) # still paints without crashing
w.set_playhead(11.5)
w.clear() # resets display state
assert w.selection() is None
def test_waveform_drag_emits_selection(win):
w = win._wave
w.resize(400, 96)
w.set_view(10.0, 4.0)
w.set_selection(11.0, 13.0)
got = []
w.selection_changed.connect(lambda s, e: got.append((s, e)))
# new selection: press near t=10.5 (not on a handle), drag to t=13.5, release
w._begin_drag_at_x(w._t_to_px(10.5))
w._drag_to_x(w._t_to_px(13.5))
w._end_drag()
assert got, "selection_changed should fire on release"
s, e = w.selection()
assert 10.0 <= s < e <= 14.0
def test_waveform_zoom_emits_view(win):
w = win._wave
w.resize(400, 96)
w.set_view(10.0, 4.0)
seen = []
w.view_changed.connect(lambda s, d: seen.append((s, d)))
w._zoom_at_x(200, 0.5) # zoom in about the middle
assert seen and seen[-1][1] < 4.0
def test_waveform_selection_syncs_cursor_and_length(win):
win._file_path = "/x/video.mp4"
win._cursor = 10.0
win._spn_audio_len.setValue(3.0)
win._wave.set_view(10.0, 6.0)
win._wave.set_selection(11.0, 14.0)
win._on_wave_selection_changed(11.0, 14.0)
assert win._cursor == 11.0
assert abs(win._spn_audio_len.value() - 3.0) < 1e-6
def test_audition_button_present_and_safe(win):
from PyQt6.QtWidgets import QPushButton
assert isinstance(win._btn_audio_play, QPushButton)
assert win._btn_audio_play.isCheckable()
assert win._btn_audio_play.text() == "▶ Play"
# No file loaded -> toggling on must be a safe no-op that snaps back to unchecked.
win._file_path = ""
win._btn_audio_play.setChecked(True) # fires _on_audio_audition(True)
assert win._btn_audio_play.isChecked() is False
# Stopping when nothing is playing must be safe.
win._stop_audition()
assert win._btn_audio_play.isChecked() is False
assert win._btn_audio_play.text() == "▶ Play"
assert win._audition_proc is None
def test_audition_clears_waveform_playhead(win):
win._wave.set_playhead(5.0)
win._stop_audition()
assert win._wave._playhead is None
assert not win._audition_playhead_timer.isActive()
def test_merge_list_add_remove_reorder(win, tmp_path):
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"
a.write_bytes(b""); b.write_bytes(b"")
win._merge_add_paths([str(a), str(b)])
assert win._merge_list.count() == 2
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(a)
win._merge_list.setCurrentRow(1)
win._merge_move(-1) # move b up
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(b)
win._merge_list.setCurrentRow(0)
win._merge_remove_selected()
assert win._merge_list.count() == 1
def test_merge_add_selection_no_file_safe(win):
win._file_path = ""
win._merge_list.clear()
win._on_merge_add_selection() # must not raise
assert win._merge_list.count() == 0
def test_merge_move_bounds_safe(win, tmp_path):
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"")
win._merge_list.clear()
win._merge_add_paths([str(a), str(b)])
win._merge_list.setCurrentRow(0)
win._merge_move(-1) # already top -> no-op
assert win._merge_list.currentRow() == 0
win._merge_list.setCurrentRow(1)
win._merge_move(1) # already bottom -> no-op
assert win._merge_list.currentRow() == 1
win._merge_list.setCurrentRow(-1)
win._merge_move(1) # no selection -> safe
win._merge_remove_selected() # no selection -> safe
assert win._merge_list.count() == 2
def test_merge_add_stores_duration_role(win, tmp_path):
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; a.write_bytes(b"")
win._merge_list.clear()
win._merge_add_paths([str(a)])
# empty fixture -> ffprobe fails -> duration is None; M4 must tolerate None
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole + 1) is None
def test_merge_save_builds_command(win, tmp_path, monkeypatch):
import main as m, pytest
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"")
win._merge_list.clear()
win._merge_add_paths([str(a), str(b)])
win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 5.0) # long enough
win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0)
win._spn_crossfade.setValue(0.75)
seen = {}
class _Stop(Exception): pass
def fake(clips, xf, out, curves=None):
seen.update(clips=clips, xf=xf, out=out, curves=curves); raise _Stop
monkeypatch.setattr(m, "build_crossfade_merge_command", fake)
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), "")))
with pytest.raises(_Stop):
win._on_merge_save()
assert seen["clips"] == [str(a), str(b)]
assert seen["xf"] == [0.75] # 1 join for 2 clips
assert seen["curves"] == ["tri"]
assert seen["out"].endswith(".wav")
def test_merge_curve_combo_present(win):
from PyQt6.QtWidgets import QComboBox
assert isinstance(win._cmb_curve, QComboBox)
assert win._cmb_curve.currentData() == "tri" # default
def test_merge_render_builds_per_join_lists(win, tmp_path, monkeypatch):
import main as m, pytest
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; c = tmp_path / "c.wav"
for f in (a, b, c): f.write_bytes(b"")
win._merge_list.clear()
win._merge_add_paths([str(a), str(b), str(c)])
for i in range(3):
win._merge_list.item(i).setData(Qt.ItemDataRole.UserRole + 1, 5.0) # long enough
win._spn_crossfade.setValue(0.5)
seen = {}
class _Stop(Exception): pass
def fake(clips, xf, out, curves=None):
seen.update(clips=clips, xf=xf, out=out, curves=curves); raise _Stop
monkeypatch.setattr(m, "build_crossfade_merge_command", fake)
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), "")))
with pytest.raises(_Stop):
win._on_merge_save()
assert seen["xf"] == [0.5, 0.5] # 2 joins for 3 clips, effective = global
assert seen["curves"] == ["tri", "tri"]
def test_merge_save_blocks_short_clip(win, tmp_path, monkeypatch):
import main as m
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"")
win._merge_list.clear()
win._merge_add_paths([str(a), str(b)])
win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 0.1) # shorter than crossfade
win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0)
win._spn_crossfade.setValue(0.5)
calls = {"n": 0}
monkeypatch.setattr(m, "build_crossfade_merge_command",
lambda *a, **k: calls.__setitem__("n", calls["n"] + 1) or [])
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
staticmethod(lambda *a, **k: (_ for _ in ()).throw(AssertionError("dialog opened"))))
win._on_merge_save() # guard must block before dialog/builder
assert calls["n"] == 0
def test_merge_save_empty_is_noop(win):
win._merge_list.clear()
win._on_merge_save() # no raise, no dialog
def test_merge_preview_blocks_short_clip(win, tmp_path, monkeypatch):
import main as m
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"")
win._merge_list.clear()
win._merge_add_paths([str(a), str(b)])
win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 0.1)
win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0)
win._spn_crossfade.setValue(0.5)
calls = {"n": 0}
monkeypatch.setattr(m, "build_crossfade_merge_command",
lambda *a, **k: calls.__setitem__("n", calls["n"] + 1) or [])
win._on_merge_preview()
assert calls["n"] == 0
def test_merge_preview_empty_is_noop(win):
win._merge_list.clear()
win._on_merge_preview()
def test_merge_override_applies_and_labels(win, tmp_path):
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"")
win._merge_list.clear(); win._merge_add_paths([str(a), str(b)])
it0 = win._merge_list.item(0)
win._apply_merge_override(it0, 0.8, "exp")
assert it0.data(Qt.ItemDataRole.UserRole + 2) == 0.8
assert it0.data(Qt.ItemDataRole.UserRole + 3) == "exp"
assert "0.80s exp" in it0.text()
assert "" not in win._merge_list.item(1).text() # last item shows no join info
xfs, cvs = win._merge_join_values()
assert xfs == [0.8] and cvs == ["exp"]
def test_merge_double_click_last_is_noop(win, tmp_path):
a = tmp_path / "a.wav"; a.write_bytes(b"")
win._merge_list.clear(); win._merge_add_paths([str(a)])
win._on_merge_item_double_clicked(win._merge_list.item(0)) # only/last -> no dialog, no raise
assert win._merge_list.count() == 1
def test_audio_editor_dialog_scaffold(win):
import main as win_module
from PyQt6.QtWidgets import QPushButton
dlg = win_module.AudioEditorDialog("/nonexistent.wav", parent=win)
# version stack starts with the initial file
assert dlg._versions == ["/nonexistent.wav"]
assert dlg._ver_idx == 0
assert dlg._current() == "/nonexistent.wav"
# widgets present
assert dlg._wave is not None
for name in ("_btn_delete", "_btn_silence", "_btn_reverse", "_btn_trim",
"_btn_undo", "_btn_redo", "_btn_save_as"):
assert isinstance(getattr(dlg, name), QPushButton)
# undo disabled at the base version, redo disabled with no forward history
assert not dlg._btn_undo.isEnabled()
assert not dlg._btn_redo.isEnabled()
# a bad path decodes to an empty waveform without crashing
dlg._reload()
dlg.close()
def test_editor_delete_builds_command(win, tmp_path, monkeypatch):
import main as m, pytest
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(1.0, 3.0)
seen = {}
class _Stop(Exception): pass
def fake(inp, s, e, out):
seen.update(inp=inp, s=s, e=e); raise _Stop
monkeypatch.setattr(m, "build_audio_delete_command", fake)
with pytest.raises(_Stop):
dlg._on_delete()
assert seen["inp"] == str(src) and seen["s"] == 1.0 and seen["e"] == 3.0
def test_editor_op_no_region_is_safe(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(2.0, 2.0) # zero-width
n = {"c": 0}
monkeypatch.setattr(m, "build_audio_delete_command",
lambda *a: n.__setitem__("c", n["c"] + 1) or [])
dlg._on_delete()
assert n["c"] == 0 # guarded, builder never called
def test_editor_delete_whole_clip_blocked(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 5.0
dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(0.0, 5.0) # whole clip
n = {"c": 0}
monkeypatch.setattr(m, "build_audio_delete_command",
lambda *a: n.__setitem__("c", n["c"] + 1) or [])
dlg._on_delete()
assert n["c"] == 0 # can't delete the whole clip
def test_editor_op_uses_clip_duration_not_view(win, tmp_path, monkeypatch):
import main as m, pytest
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 5.0
dlg._wave.set_view(0.5, 4.0) # zoomed sub-window (view ends at 4.5)
dlg._wave.set_selection(1.0, 4.5)
seen = {}
class _Stop(Exception): pass
def fake(inp, s, e, out):
seen.update(s=s, e=e); raise _Stop
monkeypatch.setattr(m, "build_audio_silence_command", fake)
with pytest.raises(_Stop):
dlg._on_silence()
assert seen["e"] == 4.5 # clip duration, not the 4.0 view span
def test_editor_undo_redo(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
monkeypatch.setattr(dlg, "_reload", lambda: None)
dlg._versions = [str(src), "/t/v1.wav", "/t/v2.wav"]; dlg._ver_idx = 2
dlg._on_undo(); assert dlg._ver_idx == 1
dlg._on_undo(); assert dlg._ver_idx == 0
dlg._on_undo(); assert dlg._ver_idx == 0 # clamped at base
dlg._on_redo(); assert dlg._ver_idx == 1
dlg._on_redo(); assert dlg._ver_idx == 2
dlg._on_redo(); assert dlg._ver_idx == 2 # clamped at top
def test_editor_play_stop_safe(win, tmp_path):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._stop_play() # no proc -> safe no-op
assert dlg._btn_play.text() == "▶ Play"
def test_editor_close_cleans_temps(win, tmp_path):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
t1 = tmp_path / "t1.wav"; t1.write_bytes(b"x")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._temps.add(str(t1))
dlg.close()
assert not t1.exists()
assert os.path.exists(str(src)) # _versions[0] (entry temp) is NOT swept by the editor
def test_editor_reject_cleans_temps(win, tmp_path):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
t1 = tmp_path / "t1.wav"; t1.write_bytes(b"x")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._temps.add(str(t1))
dlg.reject() # Escape path
assert not t1.exists() # op temp swept
assert os.path.exists(str(src)) # entry temp (_versions[0]) survives
def test_audio_library_add_dedup_and_persist(win, tmp_path):
import main as m
a = tmp_path / "a.wav"; a.write_bytes(b"")
b = tmp_path / "b.wav"; b.write_bytes(b"")
win._settings.setValue("audio_library_test", [])
lib = m.AudioLibraryTab(win._settings, key="audio_library_test")
assert lib.clips() == []
lib.add_clip(str(a)); lib.add_clip(str(b)); lib.add_clip(str(a)) # dup ignored
assert lib.clips() == [str(a), str(b)] # order preserved, deduped
assert lib._list.count() == 2
# a fresh instance restores from the same key
lib2 = m.AudioLibraryTab(win._settings, key="audio_library_test")
assert lib2.clips() == [str(a), str(b)]
def test_audio_library_drops_missing_on_load(win, tmp_path):
import main as m
good = tmp_path / "g.wav"; good.write_bytes(b"")
win._settings.setValue("audio_library_test2", [str(good), "/no/such/file.wav"])
lib = m.AudioLibraryTab(win._settings, key="audio_library_test2")
assert lib.clips() == [str(good)] # missing path dropped on load
def test_audio_library_remove(win, tmp_path):
import main as m
a = tmp_path / "a.wav"; a.write_bytes(b""); b = tmp_path / "b.wav"; b.write_bytes(b"")
win._settings.setValue("audio_library_test3", [])
lib = m.AudioLibraryTab(win._settings, key="audio_library_test3")
lib.add_clip(str(a)); lib.add_clip(str(b))
lib._list.setCurrentRow(0)
lib._on_remove()
assert lib.clips() == [str(b)]
def test_audio_library_edit_emits(win, tmp_path):
import main as m
a = tmp_path / "a.wav"; a.write_bytes(b"")
win._settings.setValue("audio_library_test4", [])
lib = m.AudioLibraryTab(win._settings, key="audio_library_test4")
lib.add_clip(str(a)); lib._list.setCurrentRow(0)
got = []
lib.edit_requested.connect(lambda p: got.append(p))
lib._on_edit()
assert got == [str(a)]
def test_scan_panel_library_tab_persists(win):
import main as m
from PyQt6.QtWidgets import QTableWidget
sp = win._scan_panel
assert isinstance(sp._library, m.AudioLibraryTab)
titles = [sp._tabs.tabText(i) for i in range(sp._tabs.count())]
assert "Library" in titles
# add a fake model tab, then clear model tabs -> library survives
sp._tabs.addTab(QTableWidget(), "EAT_LARGE (3)")
sp._clear_model_tabs()
titles2 = [sp._tabs.tabText(i) for i in range(sp._tabs.count())]
assert titles2 == ["Library"] # only the library remains
assert sp._library is sp._tabs.widget(0)
def test_scan_reload_selects_model_tab_not_library(win):
# Reloading a scanned file must leave a MODEL tab current (index 1), not the
# Library at index 0 — otherwise _current_table() is None and the timeline
# shows no scan regions until the user manually clicks a model tab.
sp = win._scan_panel
sp._filename = "clip.mp4"
sp._profile = "prof"
results = {"EAT_LARGE": [(1, 0.0, 1.0, 0.9, False, 0.0, 1.0)]}
sp._on_scan_bundle_loaded("clip.mp4", "prof", set(), [], results)
assert sp._library is sp._tabs.widget(0) # Library stays at index 0
assert sp._tabs.currentIndex() == 1 # a model tab is selected
assert sp._current_table() is not None # regions resolve immediately
assert sp.current_model_name() == "EAT_LARGE"
def test_open_editor_adds_saved_clip_to_library(win, tmp_path, monkeypatch):
import main as m
saved = tmp_path / "edited.wav"; saved.write_bytes(b"")
class FakeDlg:
def __init__(self, path, parent=None):
self._last_saved = str(saved)
def exec(self): return 0
def deleteLater(self): pass
monkeypatch.setattr(m, "AudioEditorDialog", FakeDlg)
lib = win._scan_panel._library
lib._key = "audio_library_l3test" # throwaway, avoids polluting real settings
win._settings.setValue("audio_library_l3test", [])
lib._list.clear()
win._open_audio_editor("/x/in.wav")
assert str(saved) in lib.clips()
def test_library_edit_signal_opens_editor(win, tmp_path, monkeypatch):
import main as m
opened = []
monkeypatch.setattr(win, "_open_audio_editor", lambda p: opened.append(p))
win._scan_panel._library.edit_requested.emit("/some/clip.wav")
assert opened == ["/some/clip.wav"]
+237 -1
View File
@@ -1,6 +1,6 @@
import tempfile, os, json import tempfile, os, json
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs
from core.ffmpeg import build_audio_clip_command from core.ffmpeg import build_audio_clip_command, build_crossfade_merge_command, build_audio_delete_command, build_audio_silence_command, build_audio_reverse_command
from core.annotations import build_annotation_json_path, upsert_clip_annotation from core.annotations import build_annotation_json_path, upsert_clip_annotation
from main import ProcessedDB from main import ProcessedDB
@@ -76,6 +76,197 @@ def test_audio_clip_command_extension_case_insensitive():
assert "flac" in build_audio_clip_command("/in.mp4", 0, 1, "/o/A.FLAC") assert "flac" in build_audio_clip_command("/in.mp4", 0, 1, "/o/A.FLAC")
def test_audio_edit_filters_empty_when_defaults():
from core.ffmpeg import audio_edit_filters
assert audio_edit_filters(duration=3.0) == []
def test_audio_edit_filters_fade_normalize_gain():
from core.ffmpeg import audio_edit_filters
f = audio_edit_filters(duration=10.0, fade_in=0.5, fade_out=2.0,
normalize=True, gain_db=-3.0)
assert "afade=t=in:st=0:d=0.5" in f
assert "afade=t=out:st=8.0:d=2.0" in f # fade-out starts at duration - fade_out
assert "loudnorm" in f
assert "volume=-3.0dB" in f
def test_audio_clip_command_no_filters_unchanged():
cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav")
assert "-af" not in cmd
def test_audio_clip_command_appends_filter_chain():
cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav",
filters=["afade=t=in:st=0:d=0.5", "loudnorm"])
i = cmd.index("-af")
assert cmd[i + 1] == "afade=t=in:st=0:d=0.5,loudnorm"
assert i < len(cmd) - 1 and cmd[-1] == "/o/a.wav"
def test_merge_single_clip_reencodes():
cmd = build_crossfade_merge_command(["/a.wav"], 0.5, "/o/out.mp3")
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
assert "libmp3lame" in cmd # codec by out ext
assert "acrossfade" not in " ".join(cmd)
assert "-map" not in cmd
assert "-filter_complex" not in cmd
assert cmd[-1] == "/o/out.mp3"
def test_merge_two_clips_acrossfade():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/out.wav")
assert cmd.count("-i") == 2
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5" in fc
assert "[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd # codec by out ext (multi-clip path)
def test_merge_three_clips_chains():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert fc.count("acrossfade=d=1.0") == 2 # two joins
assert "[0][1]acrossfade=d=1.0[a1]" in fc
assert "[a1][2]acrossfade=d=1.0[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_merge_zero_crossfade_uses_concat():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "concat=n=2:v=0:a=1" in fc
assert "acrossfade" not in fc
def test_merge_empty_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command([], 0.5, "/o/o.wav")
def test_merge_per_join_crossfades():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], [0.5, 1.5], "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5[a1]" in fc
assert "[a1][2]acrossfade=d=1.5[out]" in fc
def test_merge_curve_emitted_when_non_default():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/o.wav", curves="exp")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.5:c1=exp:c2=exp[out]" in fc
def test_merge_default_curve_omits_c1c2():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/o.wav") # tri default
fc = cmd[cmd.index("-filter_complex") + 1]
assert "c1=" not in fc # tri is ffmpeg's default -> omit
def test_merge_per_join_curves_list():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 0.5, "/o/o.wav",
curves=["tri", "exp"])
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5[a1]" in fc # tri -> no suffix
assert "acrossfade=d=0.5:c1=exp:c2=exp[out]" in fc # exp on 2nd join
def test_merge_mixed_zero_crossfade():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], [0.0, 0.5], "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]concat=n=2:v=0:a=1[a1]" in fc # butt-join 1st
assert "acrossfade=d=0.5" in fc # crossfade 2nd
def test_merge_crossfade_list_length_mismatch_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], [0.5], "/o/o.wav")
with pytest.raises(ValueError):
build_crossfade_merge_command(["/a.wav", "/b.wav"], [0.5, 0.5], "/o/o.wav")
def test_merge_curves_list_length_mismatch_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 0.5, "/o/o.wav", curves=["exp"])
def test_merge_unknown_curve_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/o.wav", curves="bogus")
def test_merge_three_clips_zero_concat_chain():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 0.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert fc.count("concat=n=2:v=0:a=1") == 2
assert "acrossfade" not in fc
# --- destructive audio region ops (delete/silence/reverse) ---
def test_audio_delete_command():
cmd = build_audio_delete_command("/in.wav", 1.0, 3.0, "/o/o.wav")
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=1.0" in fc # keep [0,1]
assert "atrim=start=3.0" in fc # keep [3,end]
assert "concat=n=2:v=0:a=1[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd # codec by ext
assert cmd[-1] == "/o/o.wav"
def test_audio_silence_command():
cmd = build_audio_silence_command("/in.wav", 1.0, 3.0, "/o/o.mp3")
af = cmd[cmd.index("-af") + 1]
# volume=0 gated to the region; commas inside between() are escaped for the filtergraph
assert af == "volume=0:enable='between(t\\,1.0\\,3.0)'"
assert "libmp3lame" in cmd
assert cmd[-1] == "/o/o.mp3"
def test_audio_reverse_command():
cmd = build_audio_reverse_command("/in.wav", 1.0, 3.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=1.0" in fc # head [0,1]
assert "atrim=start=1.0:end=3.0" in fc # middle [1,3]
assert "areverse" in fc # reversed middle
assert "atrim=start=3.0" in fc # tail [3,end]
assert "concat=n=3:v=0:a=1[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_audio_delete_empty_head():
# deleting from 0 still produces a valid 2-branch concat (head is empty but harmless)
cmd = build_audio_delete_command("/in.wav", 0.0, 2.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=0.0" in fc and "atrim=start=2.0" in fc
def test_audio_heal_delete_command_crossfades_join():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 2.0, 4.0, "/o/o.wav", crossfade=0.1)
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=2.0" in fc
assert "atrim=start=4.0" in fc
assert "acrossfade=d=0.1:c1=qsin:c2=qsin[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd
assert cmd[-1] == "/o/o.wav"
def test_audio_heal_delete_command_auto_crossfade_clamped():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 10.0, 12.0, "/o/o.mp3")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.25:c1=qsin:c2=qsin[out]" in fc
assert "libmp3lame" in cmd
def test_audio_heal_delete_command_near_start_shortens_crossfade():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 0.03, 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.03:c1=qsin:c2=qsin[out]" in fc
def test_audio_heal_delete_command_rejects_invalid_region():
import pytest
from core.ffmpeg import build_audio_heal_delete_command
with pytest.raises(ValueError):
build_audio_heal_delete_command("/in.wav", 3.0, 3.0, "/o/o.wav")
# --- ProcessedDB --- # --- ProcessedDB ---
def test_db_add_and_get_markers(): def test_db_add_and_get_markers():
@@ -515,3 +706,48 @@ def test_audio_extract_ltx2_duration():
duration=frames / fps) duration=frames / fps)
assert "-t" in cmd assert "-t" in cmd
assert cmd[cmd.index("-t") + 1] == str(frames / fps) assert cmd[cmd.index("-t") + 1] == str(frames / fps)
# --- waveform peak reduction + region decode ---
def test_peaks_downsamples_to_bucket_count():
from core.waveform import peaks
import numpy as np
samples = np.sin(np.linspace(0, 100, 10000)).astype("float32")
p = peaks(samples, buckets=64)
assert len(p) == 64
assert all(0.0 <= v <= 1.0 for v in p)
import pytest
assert max(p) == pytest.approx(1.0)
def test_peaks_empty_returns_zeros():
from core.waveform import peaks
assert peaks(None, buckets=16) == [0.0] * 16
import numpy as np
assert peaks(np.zeros(0, dtype="float32"), buckets=8) == [0.0] * 8
def test_load_region_samples_bad_path_returns_empty():
from core.waveform import load_region_samples
import numpy as np
out = load_region_samples("/no/such/file.mp4", 0.0, 1.0)
assert isinstance(out, np.ndarray)
assert out.size == 0
def test_time_pixel_roundtrip():
from core.waveform import t_to_x, x_to_t
assert x_to_t(0, 400, 10.0, 4.0) == 10.0
assert x_to_t(400, 400, 10.0, 4.0) == 14.0
assert x_to_t(200, 400, 10.0, 4.0) == 12.0
assert t_to_x(12.0, 400, 10.0, 4.0) == 200
for x in (0, 37, 200, 399):
assert abs(t_to_x(x_to_t(x, 400, 10.0, 4.0), 400, 10.0, 4.0) - x) <= 1
def test_time_pixel_guards():
from core.waveform import t_to_x, x_to_t
assert x_to_t(50, 0, 10.0, 4.0) == 10.0 # zero width -> view_start
assert x_to_t(50, 400, 10.0, 0.0) == 10.0 # zero span -> view_start
assert t_to_x(12.0, 400, 10.0, 0.0) == 0 # zero span -> 0