Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d74066b0 | ||
|
|
8f354254b2 | ||
|
|
a1e90f135f | ||
|
|
443ba225cd | ||
|
|
d36c61a6d5 | ||
|
|
ccad721c98 | ||
|
|
fe95076195 | ||
|
|
36fc1b9591 | ||
|
|
6cfac8baa9 | ||
|
|
9838a4fcfd | ||
|
|
b52b3e19be | ||
|
|
678f6e5cc4 | ||
|
|
fec08cfcb3 | ||
|
|
7ff870ee88 | ||
|
|
158483ce42 | ||
|
|
0e82a82cde | ||
|
|
f32d303d26 | ||
|
|
5dc7499d7f | ||
|
|
7f414d80b8 | ||
|
|
a993de615c | ||
|
|
283bc65588 | ||
|
|
d8b890d679 | ||
|
|
4a34a87d37 | ||
|
|
4d54f29514 | ||
|
|
a11e4859dd | ||
|
|
1553d7faf0 | ||
|
|
68368b4ff6 | ||
|
|
65200e482e | ||
|
|
a298418156 | ||
|
|
449b3dfa72 | ||
|
|
356cfcc7d7 | ||
|
|
25fbf8142f | ||
|
|
7ca894d585 | ||
|
|
ee7de4d83c | ||
|
|
86980b38cf | ||
|
|
799b064703 | ||
|
|
43326730f2 | ||
|
|
14ec29dbbe | ||
|
|
3922fc5174 | ||
|
|
b8aa1606d8 | ||
|
|
de7da3cad6 | ||
|
|
52417ea1ee | ||
|
|
7573277a03 | ||
|
|
578ad87b1a | ||
|
|
c57c078100 | ||
|
|
7501c4729b | ||
|
|
12eaa944a7 | ||
|
|
4c5c4276c1 | ||
|
|
8e03b97742 | ||
|
|
c9a4ac584f | ||
|
|
4b3b25ffae | ||
|
|
52ce2d451a | ||
|
|
f584e54a8c | ||
|
|
8f18c7a817 | ||
|
|
e14cebd081 |
@@ -3,6 +3,7 @@ __pycache__/
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
.venv/
|
||||
models/
|
||||
cache/
|
||||
|
||||
@@ -26,13 +26,42 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
|
||||
|
||||
- **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/L for frame-by-frame, Shift for 1-second steps
|
||||
- **Batch export** — export multiple overlapping clips per cut point with configurable count and spread offset
|
||||
- **Two export formats** — H.264 MP4 with lossless PCM audio, or WebP image sequence (frames + `.wav`)
|
||||
- **Three export modes** — H.264 MP4 with lossless PCM audio, WebP image sequence (frames + `.wav`), or lossless/fast ffmpeg stream copy that preserves the source container
|
||||
- **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; click the video or crop bar to reposition
|
||||
- **Random portrait/square** — optionally apply a random crop to a subset of each batch
|
||||
- **Resize** — scale short side to a fixed pixel size (e.g. 512)
|
||||
- **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox
|
||||
- **Long clips** — clip duration is no longer capped at 30 seconds
|
||||
- **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
|
||||
|
||||
- **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs
|
||||
@@ -43,6 +72,7 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
|
||||
- **Hard negatives** — mark false positives to refine training
|
||||
- **Model versioning** — timestamped backups with rollback support
|
||||
- **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
|
||||
|
||||
|
||||
+188
-5
@@ -82,11 +82,12 @@ def build_ffmpeg_command(
|
||||
target_fps: float | None = None,
|
||||
snap32: bool = False,
|
||||
frames: int | None = None,
|
||||
stream_copy: bool = False,
|
||||
) -> list[str]:
|
||||
# -ss before -i: fast input-seeking. Safe here because we always re-encode,
|
||||
# so there is no keyframe-alignment issue from pre-input seek.
|
||||
# Re-encoded output is not constrained to source keyframes. Stream-copy
|
||||
# output remains keyframe-limited even though it uses the same fast seek.
|
||||
# Image sequences always use libwebp, so skip HW encoder setup.
|
||||
use_hw_vaapi = (encoder == "h264_vaapi" and not image_sequence
|
||||
use_hw_vaapi = (not stream_copy and encoder == "h264_vaapi" and not image_sequence
|
||||
and sys.platform == "linux")
|
||||
cmd = [_bin("ffmpeg"), "-y"]
|
||||
|
||||
@@ -96,6 +97,31 @@ def build_ffmpeg_command(
|
||||
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
|
||||
"-vaapi_device", vaapi_dev]
|
||||
|
||||
if stream_copy:
|
||||
incompatible = (
|
||||
image_sequence or short_side is not None or portrait_ratio is not None
|
||||
or target_fps is not None or snap32 or frames is not None
|
||||
)
|
||||
if incompatible:
|
||||
raise ValueError("Stream copy cannot be combined with image or video transforms")
|
||||
# Matroska/WebM input seeking can retain a whole cluster of keyframe
|
||||
# pre-roll and then count -t from its shifted timestamps. Keeping source
|
||||
# timestamps avoids that extension. MOV/MP4 needs normal timestamp
|
||||
# rebasing instead, so this workaround is deliberately container-only.
|
||||
ext = os.path.splitext(output_path)[1].lower()
|
||||
timestamp_args = (
|
||||
["-copyts", "-start_at_zero"] if ext in (".mkv", ".webm") else []
|
||||
)
|
||||
return cmd + [
|
||||
"-threads", "0",
|
||||
"-ss", str(start),
|
||||
"-i", input_path,
|
||||
"-t", str(duration),
|
||||
*timestamp_args,
|
||||
"-c", "copy",
|
||||
output_path,
|
||||
]
|
||||
|
||||
cmd += [
|
||||
"-threads", "0",
|
||||
"-ss", str(start),
|
||||
@@ -201,12 +227,37 @@ def probe_duration(path: str) -> float | 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,
|
||||
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
|
||||
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()
|
||||
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
|
||||
af = ["-af", ",".join(filters)] if filters else []
|
||||
return [
|
||||
_bin("ffmpeg"), "-y",
|
||||
"-ss", str(start),
|
||||
@@ -214,10 +265,142 @@ def build_audio_clip_command(input_path: str, start: float, duration: float,
|
||||
"-t", str(duration),
|
||||
"-vn",
|
||||
*codec,
|
||||
*af,
|
||||
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]:
|
||||
"""Probe ffmpeg for available H.264 hardware encoders.
|
||||
|
||||
|
||||
+5
-2
@@ -25,14 +25,17 @@ def _log(*args) -> None:
|
||||
|
||||
|
||||
def build_export_path(folder: str, basename: str, counter: int,
|
||||
sub: int | None = None, tag: str | None = None) -> str:
|
||||
sub: int | None = None, tag: str | None = None,
|
||||
extension: str = ".mp4") -> str:
|
||||
"""Build clip output path. *folder* should be the vid folder (e.g. .../mp4/vid_001)."""
|
||||
name = f"{basename}_{counter:03d}"
|
||||
if tag is not None:
|
||||
name = f"{name}_{tag}"
|
||||
if sub is not None:
|
||||
name = f"{name}_{sub}"
|
||||
return os.path.join(folder, name + ".mp4")
|
||||
if extension and not extension.startswith("."):
|
||||
extension = "." + extension
|
||||
return os.path.join(folder, name + extension)
|
||||
|
||||
|
||||
def build_sequence_dir(folder: str, basename: str, counter: int,
|
||||
|
||||
@@ -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_end−sel_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).
|
||||
@@ -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.0–10.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.0–10.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.
|
||||
@@ -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.
|
||||
+1190
-3
File diff suppressed because it is too large
Load Diff
+271
-1
@@ -1,6 +1,6 @@
|
||||
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 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 main import ProcessedDB
|
||||
|
||||
@@ -18,6 +18,10 @@ def test_build_export_path_sub():
|
||||
assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001_0.mp4"
|
||||
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001_2.mp4"
|
||||
|
||||
def test_build_export_path_custom_extension():
|
||||
assert build_export_path("/out", "clip", 1, sub=0, extension=".mkv") == "/out/clip_001_0.mkv"
|
||||
assert build_export_path("/out", "clip", 1, extension="mov") == "/out/clip_001.mov"
|
||||
|
||||
def test_build_sequence_dir_sub():
|
||||
assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001_0"
|
||||
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001_1"
|
||||
@@ -54,6 +58,36 @@ def test_ffmpeg_command_with_resize():
|
||||
assert "scale" in vf_value
|
||||
assert cmd[-1] == "/out/clip_001.mp4"
|
||||
|
||||
def test_ffmpeg_command_stream_copy():
|
||||
cmd = build_ffmpeg_command(
|
||||
"/in/video.mkv", 12.5, "/out/clip_001.mkv",
|
||||
duration=3600.0, stream_copy=True,
|
||||
)
|
||||
assert cmd[cmd.index("-ss") + 1] == "12.5"
|
||||
assert cmd[cmd.index("-t") + 1] == "3600.0"
|
||||
assert cmd.index("-ss") < cmd.index("-i")
|
||||
assert "-copyts" in cmd
|
||||
assert "-start_at_zero" in cmd
|
||||
assert cmd[cmd.index("-c") + 1] == "copy"
|
||||
assert "-c:v" not in cmd
|
||||
assert "-c:a" not in cmd
|
||||
assert cmd[-1] == "/out/clip_001.mkv"
|
||||
|
||||
def test_ffmpeg_command_stream_copy_rejects_transforms():
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="Stream copy"):
|
||||
build_ffmpeg_command(
|
||||
"/in/video.mkv", 0.0, "/out/clip.mkv",
|
||||
short_side=256, stream_copy=True,
|
||||
)
|
||||
|
||||
def test_ffmpeg_command_stream_copy_mp4_uses_normal_timestamp_rebasing():
|
||||
cmd = build_ffmpeg_command(
|
||||
"/in/video.mp4", 12.5, "/out/clip.mp4", stream_copy=True,
|
||||
)
|
||||
assert "-copyts" not in cmd
|
||||
assert "-start_at_zero" not in cmd
|
||||
|
||||
|
||||
def test_audio_clip_command_exact_length():
|
||||
cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav")
|
||||
@@ -76,6 +110,197 @@ def test_audio_clip_command_extension_case_insensitive():
|
||||
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 ---
|
||||
|
||||
def test_db_add_and_get_markers():
|
||||
@@ -515,3 +740,48 @@ def test_audio_extract_ltx2_duration():
|
||||
duration=frames / fps)
|
||||
assert "-t" in cmd
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user