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 — seeconftest.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(addaudio_edit_filters, extendbuild_audio_clip_commandat 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.pyimports (line ~22),_build_control_deck(4666-4685), the builder call at 4498, and_build_scan_tabdef (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
- Add
QToolBox, QGroupBoxto thePyQt6.QtWidgetsimport block (line ~22, next toQTabWidget, QTabBar):
QTableWidget, QTableWidgetItem, QTabWidget, QTabBar, QHeaderView,
QToolBox, QGroupBox,
QGridLayout,
- In
_build_control_deck, replace the three_tab_scanlines (4668, 4678-4680) and the_deck_panels/addTablines (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)
-
Rename the method
def _build_scan_tab(self)→def _build_audio_tab(self)and changeQGridLayout(self._tab_scan)→QGridLayout(self._tab_audio)(temporary; Task 4 rewrites the body). -
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.pytransport-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_row
… threshold 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
QVBoxLayoutandQLabelare imported (they are — used throughout). IfQVBoxLayoutis 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_filtersat 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 importedmainmodule in the test file; adjust to the fixture's existing import alias. If patchingos.path.existsglobally is too broad, patchmain.os.path.existsonly within the call or assert oncapturedbefore the exists check by makingfake_cmdraise 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 inmain.py(nearTimelineWidget, ~line 1857) OR a smallcore/waveform.pyfor the peak-reduction (testable) + a thin painter inmain.py. - Modify:
_build_audio_tab(add to row 4 of the extract grid),_update_audio_region(6394) to refresh peaks. - Test:
tests/test_utils.pyfor 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 fails — pytest 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 pass — pytest 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_playconstruction 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;
QToolBoxopens 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
loudnormis 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_editsmonkeypatch details depend on howmainimportsbuild_audio_clip_command/QFileDialog/subprocess; adapt the patch targets to the actual module namespace.