Files
8-cut/docs/plans/2026-07-02-audio-tab-phase1-implementation.md
T

649 lines
26 KiB
Markdown

# 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.