docs: audio tab Phase 1 implementation plan (extract/edit/classify)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user