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

26 KiB

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

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:

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:

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

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

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):
    QTableWidget, QTableWidgetItem, QTabWidget, QTabBar, QHeaderView,
    QToolBox, QGroupBox,
    QGridLayout,
  1. In _build_control_deck, replace the three _tab_scan lines (4668, 4678-4680) and the _deck_panels/addTab lines (4682, 4685):
        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)
  1. 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).

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

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

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:

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

        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

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

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_rowthreshold widgets — verbatim, just parented to the new scan_page):

    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

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)

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:

        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:

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

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)

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 failspytest tests/test_utils.py::test_peaks_downsamples_to_bucket_count -v → FAIL (no module).

Step 3: Implement core/waveform.py:

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 passpytest tests/test_utils.py -k peaks -v → PASS.

Step 5: Commit

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

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

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:

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

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.