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