diff --git a/docs/plans/2026-07-02-audio-tab-phase1-implementation.md b/docs/plans/2026-07-02-audio-tab-phase1-implementation.md new file mode 100644 index 0000000..8a45bcd --- /dev/null +++ b/docs/plans/2026-07-02-audio-tab-phase1-implementation.md @@ -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.