# Suno Master Overhaul Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **This run executes via cursor-orchestrator.**

**Goal:** Three-phase pipeline (stem repair/remix → rebuilt master bus → reference-matched verification) that turns Suno output into professional masters.

**Architecture:** New package `tools/automaster_app/pipeline/` containing all new DSP. Phase A: demucs stems + per-stem repair chains + SBR-style HF extension. Phase B: match-EQ (linear-phase FIR) → 4-band LR4 multiband compression → oversampled glue saturation → width vs reference → lookahead true-peak limiter. Phase C: LUFS-matched metric gate vs genre reference profiles. Old broken modules retired per spec §3.7.

**Tech Stack:** Python 3, numpy/scipy, soundfile, pyloudnorm, librosa (existing); **new:** demucs (htdemucs_ft, torch CPU), pedalboard.

**Spec:** `docs/specs/2026-06-11-suno-master-overhaul-design.md`

**Conventions (apply to every task):**
- Audio arrays are float64, shape `(n_samples, n_channels)` (soundfile convention). Mono helpers accept 1-D.
- pedalboard needs float32 `(channels, samples)`. Always use the `run_pb()` helper from Task 2.
- Tests live in `./tmp/tests/` (repo-relative), are NEVER committed (user rule: git = runtime only). Run with `python -m pytest tmp/tests/<file> -v` from repo root.
- Commits stage ONLY `tools/automaster_app/` paths. Terse messages.
- All new files: `tools/automaster_app/pipeline/` package.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1 (env/deps) | none (pip + model download) | single task |
| 2 | Task 2 (filters+helpers), Task 3 (saturation), Task 4 (tp_limiter), Task 5 (hf_extension), Task 6 (reference_engine), Task 7 (stem_separator) | pipeline/filters.py, pipeline/saturation.py, pipeline/tp_limiter.py, pipeline/hf_extension.py, pipeline/reference_engine.py, pipeline/stem_separator.py | ✅ no overlap |
| 3 | Task 8 (match_eq), Task 9 (mb_compressor), Task 10 (stem_chains), Task 11 (width) | pipeline/match_eq.py, pipeline/mb_compressor.py, pipeline/stem_chains.py, pipeline/width.py | ✅ no overlap |
| 4 | Task 12 (remixer+orchestrator), Task 13 (verify) | pipeline/remixer.py + pipeline/orchestrator.py, pipeline/verify.py | ✅ no overlap |
| 5 | Task 14 (CLI/main+worker), Task 15 (retire old modules) | main.py + worker_pro.py, modules/* + processor_pro.py | ✅ no overlap |
| 6 | Task 16 (integration corpus + gate + evaluate_library) | evaluate_library.py | single task |

Semantic deps honored: match_eq/width need reference schema (T6); mb_compressor/stem_chains need filters (T2) + saturation (T3); orchestrator needs everything in waves 2-3; verify needs reference_engine + tp_limiter (true-peak fn); CLI needs orchestrator; retire-modules needs replacements to exist.

---

### Task 1: Environment & dependencies

**Wave:** 1
**Blocks:** all
**Blocked by:** —

**Files:** none committed (environment only).

- [ ] **Step 1: Install deps into project environment**

Find the project's python env first: `which python3; python3 -c "import soundfile, librosa, pyloudnorm; print('env ok')"`. If a conda/venv is used by the project (check `../activate-conda.sh` at repo root), activate it first.

```bash
pip install "demucs>=4.0" pedalboard
```

- [ ] **Step 2: Verify imports + download htdemucs_ft weights (one-time, ~300MB)**

```bash
python3 - <<'EOF'
import torch, pedalboard
print("torch", torch.__version__, "threads", torch.get_num_threads())
torch.set_num_threads(16)
from demucs.pretrained import get_model
m = get_model("htdemucs_ft")   # triggers weight download to ~/.cache
print("model ok:", type(m).__name__, "sources:", m.sources)
from pedalboard import Compressor, PeakFilter
print("pedalboard ok")
EOF
```
Expected: `model ok: BagOfModels sources: ['drums', 'bass', 'other', 'vocals']`, `pedalboard ok`.

- [ ] **Step 3: Create package + test dirs**

```bash
mkdir -p tools/automaster_app/pipeline tmp/tests refs
touch tools/automaster_app/pipeline/__init__.py
```

No commit (empty package committed with first module in Wave 2).

---

### Task 2: `filters.py` — LR4 crossovers + shared helpers

**Wave:** 2
**Blocks:** Task 9, Task 10
**Blocked by:** Task 1

**Files:**
- Create: `tools/automaster_app/pipeline/filters.py`
- Test: `tmp/tests/test_filters.py`

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_filters.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.filters import lr4_split, lr4_bands, run_pb

SR = 44100

def _pink(n, ch=2, seed=7):
    rng = np.random.default_rng(seed)
    white = rng.standard_normal((n, ch))
    f = np.fft.rfft(white, axis=0)
    f[1:] /= np.sqrt(np.arange(1, f.shape[0]))[:, None]
    p = np.fft.irfft(f, n=n, axis=0)
    return p / np.max(np.abs(p))

def _spectrum_db(x):
    mag = np.abs(np.fft.rfft(x[:, 0] * np.hanning(len(x))))
    return 20 * np.log10(mag + 1e-12)

def test_lr4_split_flat_sum():
    x = _pink(SR * 2)
    low, high = lr4_split(x, 700.0, SR)
    s = low + high
    a, b = _spectrum_db(x), _spectrum_db(s)
    band = slice(20, len(a) // 2)          # ignore DC + extreme HF bins
    assert np.max(np.abs(a[band] - b[band])) < 0.1

def test_lr4_bands_flat_sum_and_count():
    x = _pink(SR * 2)
    bands = lr4_bands(x, [120.0, 700.0, 6000.0], SR)
    assert len(bands) == 4
    s = sum(bands)
    a, b = _spectrum_db(x), _spectrum_db(s)
    band = slice(20, len(a) // 2)
    assert np.max(np.abs(a[band] - b[band])) < 0.15

def test_run_pb_roundtrip_shape_dtype():
    from pedalboard import Pedalboard, Gain
    x = _pink(SR // 2)
    y = run_pb(Pedalboard([Gain(gain_db=0.0)]), x, SR)
    assert y.shape == x.shape and y.dtype == np.float64
    assert np.max(np.abs(y - x)) < 1e-4
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_filters.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'automaster_app.pipeline.filters'`

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/filters.py
"""LR4 (Linkwitz-Riley 4th order) crossovers and shared helpers.

LR4 = two cascaded 2nd-order Butterworth sections. LP+HP of one split sums
to an allpass (flat magnitude). Multi-band tree splits apply each later
crossover's allpass to already-extracted bands so the full sum stays flat.
"""
import numpy as np
from scipy.signal import butter, sosfilt


def _lr4_pair(freq, sr):
    lo = butter(2, freq, btype="low", fs=sr, output="sos")
    hi = butter(2, freq, btype="high", fs=sr, output="sos")
    return lo, hi


def _apply2(sos, x):
    return sosfilt(sos, sosfilt(sos, x, axis=0), axis=0)


def lr4_split(audio, freq, sr):
    """Return (low, high); low+high has flat magnitude."""
    lo, hi = _lr4_pair(freq, sr)
    return _apply2(lo, audio), _apply2(hi, audio)


def lr4_allpass(audio, freq, sr):
    low, high = lr4_split(audio, freq, sr)
    return low + high


def lr4_bands(audio, freqs, sr):
    """Split into len(freqs)+1 bands, phase-aligned so sum(bands) is flat."""
    bands = []
    rest = audio
    for f in freqs:
        low, rest = lr4_split(rest, f, sr)
        bands = [lr4_allpass(b, f, sr) for b in bands]
        bands.append(low)
    bands.append(rest)
    return bands


def bandpass_lr4(audio, f_lo, f_hi, sr):
    """Isolate [f_lo, f_hi] band plus the complementary residual (band, rest).
    band + rest has flat magnitude."""
    low, mid_high = lr4_split(audio, f_lo, sr)
    band, high = lr4_split(mid_high, f_hi, sr)
    rest = lr4_allpass(low, f_hi, sr) + high
    return band, rest


def run_pb(board, audio, sr):
    """Run a pedalboard chain on (n, ch) float64 audio, preserving shape/dtype."""
    mono = audio.ndim == 1
    x = audio[:, None] if mono else audio
    y = board(x.T.astype(np.float32), sr).T.astype(np.float64)
    if y.shape[0] != x.shape[0]:           # pedalboard never changes length, guard anyway
        y = y[: x.shape[0]]
    return y[:, 0] if mono else y
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_filters.py -v`
Expected: 3 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/__init__.py tools/automaster_app/pipeline/filters.py
git commit -m "pipeline: LR4 crossovers + pedalboard helper"
```

---

### Task 3: `saturation.py` — shared oversampling core + glue

**Wave:** 2
**Blocks:** Task 10, Task 12, Task 15
**Blocked by:** Task 1

**Files:**
- Create: `tools/automaster_app/pipeline/saturation.py`
- Test: `tmp/tests/test_saturation.py`

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_saturation.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.saturation import oversampled, glue_saturate

SR = 44100

def test_oversampled_identity_null():
    rng = np.random.default_rng(1)
    x = rng.standard_normal((SR, 2)) * 0.5
    y = oversampled(lambda u: u, x, factor=4)
    null_db = 20 * np.log10(np.max(np.abs(y - x)) / np.max(np.abs(x)) + 1e-12)
    assert null_db < -80

def test_oversampled_tanh_no_alias_spray():
    # 15 kHz sine driven into tanh: without OS, aliases land in-band.
    t = np.arange(SR) / SR
    x = (0.9 * np.sin(2 * np.pi * 15000 * t))[:, None]
    y = oversampled(lambda u: np.tanh(u * 3.0), x, factor=4)
    mag = np.abs(np.fft.rfft(y[:, 0] * np.hanning(SR)))
    freqs = np.fft.rfftfreq(SR, 1 / SR)
    # alias of 3rd harmonic (45k) folds to 44.1k-45k... out of band; check 100Hz-10kHz floor
    inband = mag[(freqs > 100) & (freqs < 10000)]
    fund = mag[np.argmin(np.abs(freqs - 15000))]
    assert 20 * np.log10(np.max(inband) / fund + 1e-12) < -60

def test_glue_amount_zero_is_noop():
    rng = np.random.default_rng(2)
    x = rng.standard_normal((SR // 2, 2)) * 0.3
    y = glue_saturate(x, amount=0.0)
    assert np.max(np.abs(y - x)) < 1e-9
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_saturation.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/saturation.py
"""Shared 4x-oversampled nonlinearity wrapper + master glue saturation.

Every nonlinear stage in the pipeline routes through oversampled() so harmonic
generation happens at 4x rate and fold-back lands above the audio band before
the decimation filter removes it.
"""
import numpy as np
from scipy.signal import resample_poly


def oversampled(fn, audio, factor=4):
    n = audio.shape[0]
    up = resample_poly(audio, factor, 1, axis=0)
    out = fn(up)
    down = resample_poly(out, 1, factor, axis=0)
    return down[:n]


def glue_saturate(audio, amount, drive=1.5):
    """Master-bus glue: arctan soft saturation, parallel blend.
    amount: 0..1 blend (spec: 5-10% typical)."""
    if amount <= 0.0:
        return audio
    norm = np.arctan(drive)
    wet = oversampled(lambda u: np.arctan(u * drive) / norm, audio, factor=4)
    return wet * amount + audio * (1.0 - amount)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_saturation.py -v`
Expected: 3 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/saturation.py
git commit -m "pipeline: oversampled nonlinearity core + glue"
```

---

### Task 4: `tp_limiter.py` — lookahead true-peak limiter

**Wave:** 2
**Blocks:** Task 12, Task 13, Task 15
**Blocked by:** Task 1

**Files:**
- Create: `tools/automaster_app/pipeline/tp_limiter.py`
- Test: `tmp/tests/test_tp_limiter.py`

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_tp_limiter.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.tp_limiter import limit, true_peak_db

SR = 44100

def _impulse_train_program(seconds=8):
    """Worst case: loud pink bed + repeating near-full-scale clicks."""
    rng = np.random.default_rng(3)
    n = SR * seconds
    x = rng.standard_normal((n, 2)) * 0.05
    x[::SR // 4] = 0.98
    return x

def test_ceiling_never_exceeded():
    x = _impulse_train_program()
    y, report = limit(x, SR, target_lufs=-9.0, ceiling_dbtp=-1.0)
    assert true_peak_db(y, SR) <= -0.95          # -1.0 dBTP with 0.05 dB tolerance

def test_lufs_hits_target():
    x = _impulse_train_program()
    y, report = limit(x, SR, target_lufs=-12.0, ceiling_dbtp=-1.0)
    assert abs(report["output_lufs"] - (-12.0)) <= 1.0

def test_quiet_program_only_gains():
    t = np.arange(SR * 6) / SR
    x = (0.01 * np.sin(2 * np.pi * 220 * t))[:, None] * np.ones((1, 2))
    y, report = limit(x, SR, target_lufs=-14.0, ceiling_dbtp=-1.0)
    assert report["max_gain_reduction_db"] < 0.5  # almost no GR needed

def test_dither_16bit_output_range():
    x = _impulse_train_program(2)
    y, _ = limit(x, SR, target_lufs=-14.0, bit_depth=16)
    assert np.max(np.abs(y)) <= 1.0
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_tp_limiter.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/tp_limiter.py
"""Lookahead true-peak limiter (ITU-R BS.1770-4 style detection).

Chain: LUFS gain staging -> 4x oversample -> per-sample required gain vs
ceiling -> sliding-minimum lookahead -> Hann attack smoothing -> decimated
exponential release -> apply in OS domain -> downsample -> safety clamp ->
one corrective LUFS iteration -> TPDF dither.
"""
import numpy as np
import pyloudnorm as pyln
from scipy.signal import resample_poly
from scipy.ndimage import minimum_filter1d

OS = 4
CTRL_DECIM = 16          # release loop runs at OS-rate/16 (~11 kHz at 44.1k)


def true_peak_db(audio, sr):
    up = resample_poly(audio, OS, 1, axis=0)
    return 20 * np.log10(np.max(np.abs(up)) + 1e-12)


def _gain_curve(amp, ceiling, sr_os, lookahead_ms, release_ms):
    required = np.minimum(1.0, ceiling / np.maximum(amp, 1e-12))
    la = max(1, int(sr_os * lookahead_ms / 1000.0))
    g = minimum_filter1d(required, size=2 * la + 1)
    win = np.hanning(2 * la + 1)
    win /= win.sum()
    g = np.convolve(g, win, mode="same")
    g = np.minimum(g, required)                      # smoothing must not overshoot
    # exponential release at decimated control rate
    gd = g[::CTRL_DECIM].copy()
    alpha = np.exp(-CTRL_DECIM / (release_ms / 1000.0 * sr_os))
    prev = 1.0
    for i in range(len(gd)):
        prev = min(gd[i], 1.0 - (1.0 - prev) * alpha)
        gd[i] = prev
    g_rel = np.interp(np.arange(len(g)), np.arange(len(gd)) * CTRL_DECIM, gd)
    return np.minimum(g_rel, required)


def _tpdf_dither(audio, bit_depth):
    lsb = 2.0 ** (1 - bit_depth)
    rng = np.random.default_rng(0xD17)
    noise = (rng.random(audio.shape) - rng.random(audio.shape)) * lsb
    return np.clip(audio + noise, -1.0, 1.0)


def limit(audio, sr, target_lufs, ceiling_dbtp=-1.0, lookahead_ms=5.0,
          release_ms=None, bit_depth=24):
    meter = pyln.Meter(sr)
    ceiling = 10 ** (ceiling_dbtp / 20.0)
    report = {}

    x = audio
    for _ in range(2):                                # initial + one corrective pass
        loud = meter.integrated_loudness(x)
        delta = target_lufs - loud
        if abs(delta) <= 0.5 and _ > 0:
            break
        x = x * 10 ** (delta / 20.0)

        if release_ms is None:
            peak = np.max(np.abs(x)) + 1e-12
            rms = np.sqrt(np.mean(x ** 2)) + 1e-12
            crest_db = 20 * np.log10(peak / rms)
            rel = float(np.clip(50.0 + (crest_db - 10.0) * 15.0, 50.0, 200.0))
        else:
            rel = release_ms

        up = resample_poly(x, OS, 1, axis=0)
        amp = np.max(np.abs(up), axis=1)
        g = _gain_curve(amp, ceiling, sr * OS, lookahead_ms, rel)
        report["max_gain_reduction_db"] = float(-20 * np.log10(np.min(g) + 1e-12))
        up *= g[:, None]
        x = resample_poly(up, 1, OS, axis=0)[: audio.shape[0]]
        np.clip(x, -ceiling, ceiling, out=x)          # safety clamp post-decimation

    report["output_lufs"] = float(meter.integrated_loudness(x))
    report["output_true_peak_dbtp"] = float(true_peak_db(x, sr))
    if bit_depth in (16, 24):
        x = _tpdf_dither(x, bit_depth)
    return x, report
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_tp_limiter.py -v`
Expected: 4 passed. If `test_ceiling_never_exceeded` fails marginally (decimation filter overshoot), the safety clamp bound is wrong — clamp must run AFTER downsampling (it does); verify tolerance, do not weaken the test below -0.95.

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/tp_limiter.py
git commit -m "pipeline: lookahead true-peak limiter + TPDF dither"
```

---

### Task 5: `hf_extension.py` — spectral band replication

**Wave:** 2
**Blocks:** Task 12
**Blocked by:** Task 1

**Files:**
- Create: `tools/automaster_app/pipeline/hf_extension.py`
- Test: `tmp/tests/test_hf_extension.py`

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_hf_extension.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.hf_extension import extend_hf, detect_rolloff

SR = 44100

def _bandlimited_noise(cutoff, seconds=3, seed=5):
    rng = np.random.default_rng(seed)
    n = SR * seconds
    x = rng.standard_normal((n, 2)) * 0.2
    f = np.fft.rfft(x, axis=0)
    freqs = np.fft.rfftfreq(n, 1 / SR)
    f[freqs > cutoff] = 0
    return np.fft.irfft(f, n=n, axis=0)

def _band_energy(x, lo, hi):
    f = np.abs(np.fft.rfft(x[:, 0]))
    freqs = np.fft.rfftfreq(len(x), 1 / SR)
    return np.sum(f[(freqs >= lo) & (freqs < hi)] ** 2)

def test_detects_rolloff():
    x = _bandlimited_noise(15000)
    r = detect_rolloff(x, SR)
    assert 13500 < r < 16500

def test_extends_bandlimited_input():
    x = _bandlimited_noise(15000)
    y = extend_hf(x, SR)
    before = _band_energy(x, 16000, 20000)
    after = _band_energy(y, 16000, 20000)
    assert after > before * 100          # energy appears above old rolloff
    # and it must be quieter than the band below (decaying envelope)
    assert after < _band_energy(y, 11000, 15000)

def test_noop_on_full_bandwidth():
    x = _bandlimited_noise(21000)
    y = extend_hf(x, SR)
    assert np.max(np.abs(y - x)) < 1e-9
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_hf_extension.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/hf_extension.py
"""SBR-style high-frequency bandwidth extension (the "sounds like AI" fix).

Suno/codec output rolls off ~14-16 kHz. Copy the octave below the detected
roll-off above it (STFT bin shift), shape with a decaying envelope matched to
the spectral slope below roll-off, blend shaped noise, boost on transient
frames. No-op when input already extends past 0.85*Nyquist.
"""
import numpy as np
from scipy.signal import stft, istft

NFFT = 4096
HOP = NFFT // 4
NOISE_BLEND = 0.3
SLOPE_DB_PER_OCT = -3.0
TRANSIENT_BOOST_DB = 3.0


def detect_rolloff(audio, sr):
    mono = audio.mean(axis=1) if audio.ndim == 2 else audio
    f, t, Z = stft(mono, fs=sr, nperseg=NFFT, noverlap=NFFT - HOP)
    mag = np.mean(np.abs(Z), axis=1)
    # smooth 1/6 octave
    smooth = np.convolve(mag, np.hanning(9) / np.hanning(9).sum(), mode="same")
    ref_band = smooth[(f >= 1000) & (f <= 4000)].mean()
    floor = ref_band * 10 ** (-30 / 20.0)
    above = np.where((f > 4000) & (smooth < floor))[0]
    return float(f[above[0]]) if len(above) else float(f[-1])


def extend_hf(audio, sr, rolloff_hz=None):
    nyq = sr / 2.0
    roll = detect_rolloff(audio, sr) if rolloff_hz is None else rolloff_hz
    if roll >= 0.85 * nyq:
        return audio

    out = np.empty_like(audio)
    rng = np.random.default_rng(0x5B12)
    for ch in range(audio.shape[1]):
        f, t, Z = stft(audio[:, ch], fs=sr, nperseg=NFFT, noverlap=NFFT - HOP)
        df = f[1] - f[0]
        i_roll = int(roll / df)
        i_src_lo = max(1, i_roll // 2)                  # octave below roll-off
        n_copy = min(i_roll - i_src_lo, len(f) - i_roll)
        if n_copy <= 0:
            out[:, ch] = audio[:, ch]
            continue

        src = Z[i_src_lo:i_src_lo + n_copy, :].copy()
        # decaying envelope: continue measured slope from the source octave
        octaves_up = np.log2((f[i_roll:i_roll + n_copy] + 1e-9) / f[i_roll - 1])
        decay = 10 ** ((SLOPE_DB_PER_OCT * (1 + octaves_up)) / 20.0)
        # level-match at the splice point
        splice_ratio = (np.abs(Z[i_roll - 4:i_roll, :]).mean() /
                        (np.abs(src[:4, :]).mean() + 1e-12))
        shifted = src * splice_ratio * decay[:, None]
        noise = (rng.standard_normal(shifted.shape) +
                 1j * rng.standard_normal(shifted.shape)) * np.abs(shifted) / np.sqrt(2)
        blended = shifted * (1 - NOISE_BLEND) + noise * NOISE_BLEND

        # transient frames get a boost (restores cymbal/consonant sharpness)
        frame_mag = np.abs(Z).sum(axis=0)
        flux = np.maximum(0, np.diff(frame_mag, prepend=frame_mag[0]))
        thresh = np.quantile(flux, 0.9)
        boost = np.where(flux >= thresh, 10 ** (TRANSIENT_BOOST_DB / 20.0), 1.0)
        blended *= boost[None, :]

        # crossfade +-1 kHz around the splice
        xfade_bins = max(1, int(1000 / df))
        ramp = np.linspace(0, 1, min(xfade_bins, n_copy))[:, None]
        Z[i_roll:i_roll + len(ramp), :] = (Z[i_roll:i_roll + len(ramp), :] * (1 - ramp) +
                                           blended[:len(ramp), :] * ramp)
        if n_copy > len(ramp):
            Z[i_roll + len(ramp):i_roll + n_copy, :] = blended[len(ramp):, :]

        _, y = istft(Z, fs=sr, nperseg=NFFT, noverlap=NFFT - HOP)
        out[:, ch] = y[: audio.shape[0]]
    return out
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_hf_extension.py -v`
Expected: 3 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/hf_extension.py
git commit -m "pipeline: SBR-style HF bandwidth extension"
```

---

### Task 6: `reference_engine.py` — reference profiles

**Wave:** 2
**Blocks:** Task 8, Task 11, Task 13
**Blocked by:** Task 1

**Files:**
- Create: `tools/automaster_app/pipeline/reference_engine.py`
- Test: `tmp/tests/test_reference_engine.py`

Profile JSON schema (consumed by match_eq, width, verify — exact keys):

```json
{
  "genre": "house",
  "n_refs": 3,
  "lufs": -9.2,
  "lra": 4.1,
  "plr": 8.3,
  "true_peak_dbtp": -0.8,
  "third_octave_hz": [25.0, 31.5, "...", 20000.0],
  "third_octave_db": [-12.1, "...per-band dB rel. to overall RMS, LUFS-normalized..."],
  "band_width_ratio": {"low": 0.05, "mid": 0.35, "high": 0.55},
  "width_bands_hz": [250, 4000]
}
```

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_reference_engine.py
import numpy as np, soundfile as sf, json, sys, os
sys.path.insert(0, "tools")
from automaster_app.pipeline.reference_engine import (
    build_profile, build_reference_profiles, load_profile, THIRD_OCTAVE_HZ)

SR = 44100

def _fake_ref(path, tilt_db_per_oct=-3.0, seconds=5):
    rng = np.random.default_rng(11)
    n = SR * seconds
    x = rng.standard_normal((n, 2)) * 0.2
    f = np.fft.rfft(x, axis=0)
    freqs = np.fft.rfftfreq(n, 1 / SR)
    shape = (np.maximum(freqs, 20) / 1000.0) ** (tilt_db_per_oct / 6.020)
    f *= shape[:, None]
    x = np.fft.irfft(f, n=n, axis=0)
    x /= np.max(np.abs(x)) * 1.2
    sf.write(path, x, SR)

def test_build_profile_keys_and_shapes(tmp_path):
    p = tmp_path / "ref.wav"; _fake_ref(str(p))
    prof = build_profile([str(p)], "testgenre")
    for k in ["genre", "lufs", "lra", "plr", "true_peak_dbtp",
              "third_octave_hz", "third_octave_db", "band_width_ratio"]:
        assert k in prof, k
    assert len(prof["third_octave_db"]) == len(THIRD_OCTAVE_HZ)
    assert prof["n_refs"] == 1

def test_tilted_ref_has_negative_slope(tmp_path):
    p = tmp_path / "ref.wav"; _fake_ref(str(p), tilt_db_per_oct=-4.0)
    prof = build_profile([str(p)], "g")
    db = np.array(prof["third_octave_db"])
    hz = np.array(prof["third_octave_hz"])
    lo = db[(hz > 80) & (hz < 300)].mean()
    hi = db[(hz > 5000) & (hz < 12000)].mean()
    assert lo > hi          # darker at top

def test_build_and_load_roundtrip(tmp_path):
    gdir = tmp_path / "refs" / "pop"; gdir.mkdir(parents=True)
    _fake_ref(str(gdir / "a.wav"))
    outdir = tmp_path / "profiles"
    build_reference_profiles(str(tmp_path / "refs"), str(outdir))
    prof = load_profile("pop", str(outdir))
    assert prof["genre"] == "pop" and prof["n_refs"] == 1

def test_load_missing_returns_none(tmp_path):
    assert load_profile("nope", str(tmp_path)) is None
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_reference_engine.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/reference_engine.py
"""Build per-genre reference profiles from commercial tracks in refs/<genre>/.

Profiles are the single source of truth for match_eq targets, width targets,
LUFS targets and the Phase C verification gate.
"""
import os, json, glob
import numpy as np
import soundfile as sf
import pyloudnorm as pyln
from scipy.signal import resample_poly

# ISO 1/3-octave centers 25 Hz .. 20 kHz
THIRD_OCTAVE_HZ = [25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315,
                   400, 500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150,
                   4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000]
WIDTH_BANDS_HZ = [250, 4000]
DEFAULT_PROFILE_DIR = os.path.join(os.path.dirname(__file__), "..", "references")


def third_octave_spectrum_db(audio, sr):
    """Per-band mean energy in dB relative to overall RMS."""
    mono = audio.mean(axis=1) if audio.ndim == 2 else audio
    f = np.abs(np.fft.rfft(mono * np.hanning(len(mono))))
    freqs = np.fft.rfftfreq(len(mono), 1 / sr)
    total_rms = np.sqrt(np.mean(f ** 2)) + 1e-12
    out = []
    for c in THIRD_OCTAVE_HZ:
        lo, hi = c / 2 ** (1 / 6), c * 2 ** (1 / 6)
        sel = f[(freqs >= lo) & (freqs < hi)]
        band = np.sqrt(np.mean(sel ** 2)) if len(sel) else 0.0
        out.append(20 * np.log10(band / total_rms + 1e-12))
    return out


def band_width_ratio(audio, sr):
    """S/(M+S) energy ratio per band (low/mid/high split at WIDTH_BANDS_HZ)."""
    from .filters import lr4_bands
    mid = (audio[:, 0] + audio[:, 1]) * 0.5
    side = (audio[:, 0] - audio[:, 1]) * 0.5
    names = ["low", "mid", "high"]
    ms = np.stack([mid, side], axis=1)
    bands = lr4_bands(ms, [float(b) for b in WIDTH_BANDS_HZ], sr)
    out = {}
    for name, b in zip(names, bands):
        em, es = np.mean(b[:, 0] ** 2), np.mean(b[:, 1] ** 2)
        out[name] = float(es / (em + es + 1e-12))
    return out


def build_profile(wav_paths, genre):
    from .tp_limiter import true_peak_db
    specs, lufss, lras, plrs, tps, widths = [], [], [], [], [], []
    for p in wav_paths:
        audio, sr = sf.read(p, always_2d=True)
        meter = pyln.Meter(sr)
        lufs = meter.integrated_loudness(audio)
        peak_db = 20 * np.log10(np.max(np.abs(audio)) + 1e-12)
        # loudness-normalize to -14 LUFS before spectral analysis
        norm = audio * 10 ** ((-14.0 - lufs) / 20.0)
        specs.append(third_octave_spectrum_db(norm, sr))
        lufss.append(lufs)
        plrs.append(peak_db - lufs)
        tps.append(true_peak_db(audio, sr))
        widths.append(band_width_ratio(audio, sr))
        # LRA: short-term loudness percentile spread
        st = _short_term_lufs(audio, sr, meter)
        lras.append(float(np.percentile(st, 95) - np.percentile(st, 10)) if len(st) else 0.0)
    return {
        "genre": genre,
        "n_refs": len(wav_paths),
        "lufs": float(np.mean(lufss)),
        "lra": float(np.mean(lras)),
        "plr": float(np.mean(plrs)),
        "true_peak_dbtp": float(np.mean(tps)),
        "third_octave_hz": [float(h) for h in THIRD_OCTAVE_HZ],
        "third_octave_db": [float(v) for v in np.mean(specs, axis=0)],
        "band_width_ratio": {k: float(np.mean([w[k] for w in widths]))
                             for k in ["low", "mid", "high"]},
        "width_bands_hz": WIDTH_BANDS_HZ,
    }


def _short_term_lufs(audio, sr, meter, win_s=3.0, hop_s=1.0):
    vals = []
    win, hop = int(win_s * sr), int(hop_s * sr)
    for i in range(0, len(audio) - win, hop):
        try:
            v = meter.integrated_loudness(audio[i:i + win])
            if np.isfinite(v):
                vals.append(v)
        except Exception:
            pass
    return np.array(vals)


def build_reference_profiles(refs_dir, out_dir=DEFAULT_PROFILE_DIR):
    os.makedirs(out_dir, exist_ok=True)
    built = []
    for gdir in sorted(glob.glob(os.path.join(refs_dir, "*"))):
        if not os.path.isdir(gdir):
            continue
        wavs = sorted(glob.glob(os.path.join(gdir, "*.wav")) +
                      glob.glob(os.path.join(gdir, "*.flac")))
        if not wavs:
            continue
        genre = os.path.basename(gdir)
        prof = build_profile(wavs, genre)
        with open(os.path.join(out_dir, f"{genre}.json"), "w") as fh:
            json.dump(prof, fh, indent=1)
        built.append(genre)
    return built


def load_profile(genre, profile_dir=DEFAULT_PROFILE_DIR):
    path = os.path.join(profile_dir, f"{genre}.json")
    if not os.path.exists(path):
        return None
    with open(path) as fh:
        return json.load(fh)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_reference_engine.py -v`
Expected: 4 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/reference_engine.py
git commit -m "pipeline: per-genre reference profiles"
```

---

### Task 7: `stem_separator.py` — demucs wrapper, cache, artifact gate

**Wave:** 2
**Blocks:** Task 12
**Blocked by:** Task 1

**Files:**
- Create: `tools/automaster_app/pipeline/stem_separator.py`
- Test: `tmp/tests/test_stem_separator.py`

- [ ] **Step 1: Write the failing test** (cache + gate logic only; real demucs run happens in Task 16 integration — too slow for unit tests)

```python
# tmp/tests/test_stem_separator.py
import numpy as np, soundfile as sf, sys, os
sys.path.insert(0, "tools")
from automaster_app.pipeline.stem_separator import (
    cache_dir_for, separation_quality, StemSeparationUnavailable, load_cached_stems)

SR = 44100

def test_cache_dir_is_content_hashed(tmp_path):
    p = tmp_path / "a.wav"
    sf.write(str(p), np.zeros((SR, 2)), SR)
    d1 = cache_dir_for(str(p), root=str(tmp_path / "cache"))
    sf.write(str(p), np.ones((SR, 2)) * 0.1, SR)
    d2 = cache_dir_for(str(p), root=str(tmp_path / "cache"))
    assert d1 != d2                      # content hash, not path hash

def test_separation_quality_clean():
    rng = np.random.default_rng(4)
    mix = rng.standard_normal((SR, 2)) * 0.2
    stems = {k: mix / 4.0 for k in ["drums", "bass", "other", "vocals"]}
    assert separation_quality(mix, stems) == "ok"

def test_separation_quality_degraded():
    rng = np.random.default_rng(4)
    mix = rng.standard_normal((SR, 2)) * 0.2
    stems = {k: mix / 4.0 for k in ["drums", "bass", "other", "vocals"]}
    stems["vocals"] = stems["vocals"] + rng.standard_normal((SR, 2)) * 0.05
    assert separation_quality(mix, stems) == "degraded"

def test_load_cached_roundtrip(tmp_path):
    d = tmp_path / "stems"; d.mkdir()
    for k in ["drums", "bass", "other", "vocals"]:
        sf.write(str(d / f"{k}.wav"), np.zeros((SR // 2, 2)), SR)
    stems, sr = load_cached_stems(str(d))
    assert set(stems) == {"drums", "bass", "other", "vocals"} and sr == SR
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_stem_separator.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/stem_separator.py
"""demucs htdemucs_ft wrapper with content-hash disk cache and artifact gate.

separate() never silently produces garbage: residual energy of (mix - sum(stems))
above -40 dBFS rel. input flags 'degraded' (pipeline switches to light mode);
hard failures raise StemSeparationUnavailable (pipeline bypasses Phase A).
"""
import os, hashlib
import numpy as np
import soundfile as sf

STEM_NAMES = ["drums", "bass", "other", "vocals"]
DEFAULT_CACHE = os.path.join("tmp", "stems")
RESIDUAL_GATE_DB = -40.0


class StemSeparationUnavailable(Exception):
    pass


def cache_dir_for(path, root=DEFAULT_CACHE):
    h = hashlib.sha1()
    with open(path, "rb") as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b""):
            h.update(chunk)
    return os.path.join(root, h.hexdigest())


def load_cached_stems(d):
    stems, sr = {}, None
    for name in STEM_NAMES:
        p = os.path.join(d, f"{name}.wav")
        if not os.path.exists(p):
            return None, None
        stems[name], sr = sf.read(p, always_2d=True)
    return stems, sr


def separation_quality(mix, stems):
    n = min(len(mix), min(len(s) for s in stems.values()))
    total = sum(s[:n] for s in stems.values())
    resid = mix[:n] - total
    in_rms = np.sqrt(np.mean(mix[:n] ** 2)) + 1e-12
    res_rms = np.sqrt(np.mean(resid ** 2)) + 1e-12
    return "ok" if 20 * np.log10(res_rms / in_rms) <= RESIDUAL_GATE_DB else "degraded"


def separate(path, cache_root=DEFAULT_CACHE, progress=None):
    """Return (stems dict, sr, quality). Cached after first run per content hash."""
    d = cache_dir_for(path, cache_root)
    stems, sr = load_cached_stems(d)
    if stems is None:
        try:
            import torch
            torch.set_num_threads(max(1, os.cpu_count() - 2))
            # AS-BUILT NOTE: demucs 4.0.1 has no demucs.api — implementation uses
            # get_model("htdemucs_ft") + load_track + apply_model with CLI-equivalent
            # ref mean/std normalization. segment is clamped via _resolve_segment(model)
            # to the model's max_allowed_segment (7.8s for htdemucs_ft) — segment=10
            # would RuntimeError. Quality gate compares stems against the load_track-
            # resampled mix (not the file's native rate). See
            # tools/automaster_app/pipeline/stem_separator.py for authoritative code.
            from demucs.api import Separator
            sep = Separator(model="htdemucs_ft", segment=10, progress=bool(progress))
            _, separated = sep.separate_audio_file(path)
            sr = sep.samplerate
            os.makedirs(d, exist_ok=True)
            stems = {}
            for name, tensor in separated.items():
                arr = tensor.cpu().numpy().T.astype(np.float64)   # (n, ch)
                stems[name] = arr
                sf.write(os.path.join(d, f"{name}.wav"), arr, sr)
        except (ImportError, RuntimeError, OSError) as e:
            raise StemSeparationUnavailable(str(e)) from e
    mix, mix_sr = sf.read(path, always_2d=True)
    if mix_sr != sr:
        raise StemSeparationUnavailable(f"sr mismatch {mix_sr} != {sr}")
    return stems, sr, separation_quality(mix, stems)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_stem_separator.py -v`
Expected: 4 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/stem_separator.py
git commit -m "pipeline: demucs wrapper with cache + artifact gate"
```

---

### Task 8: `match_eq.py` — reference matching EQ

**Wave:** 3
**Blocks:** Task 12
**Blocked by:** Task 6

**Files:**
- Create: `tools/automaster_app/pipeline/match_eq.py`
- Test: `tmp/tests/test_match_eq.py`

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_match_eq.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.match_eq import correction_curve_db, apply_match_eq
from automaster_app.pipeline.reference_engine import (
    THIRD_OCTAVE_HZ, third_octave_spectrum_db)

SR = 44100

def _tilted_noise(tilt_db_per_oct, seconds=4, seed=9):
    rng = np.random.default_rng(seed)
    n = SR * seconds
    x = rng.standard_normal((n, 2)) * 0.2
    f = np.fft.rfft(x, axis=0)
    freqs = np.fft.rfftfreq(n, 1 / SR)
    f *= ((np.maximum(freqs, 20) / 1000.0) ** (tilt_db_per_oct / 6.020))[:, None]
    return np.fft.irfft(f, n=n, axis=0)

def _fake_profile(audio):
    return {"third_octave_hz": [float(h) for h in THIRD_OCTAVE_HZ],
            "third_octave_db": third_octave_spectrum_db(audio, SR)}

def test_correction_capped():
    src, ref = _tilted_noise(-8.0), _tilted_noise(0.0, seed=10)
    hz, db = correction_curve_db(src, SR, _fake_profile(ref))
    assert np.max(np.abs(db)) <= 4.0 + 1e-9

def test_match_moves_spectrum_toward_ref():
    src, ref = _tilted_noise(-6.0), _tilted_noise(-2.0, seed=10)
    prof = _fake_profile(ref)
    out = apply_match_eq(src, SR, prof, pre_extension_rolloff_hz=20000.0)
    def dist(x):
        d = np.array(third_octave_spectrum_db(x, SR)) - np.array(prof["third_octave_db"])
        sel = (np.array(THIRD_OCTAVE_HZ) > 50) & (np.array(THIRD_OCTAVE_HZ) < 12000)
        return np.mean(np.abs(d[sel]))
    assert dist(out) < dist(src)
    assert out.shape == src.shape          # latency compensated, same length

def test_no_boost_above_rolloff():
    src, ref = _tilted_noise(-6.0), _tilted_noise(0.0, seed=10)
    hz, db = correction_curve_db(src, SR, _fake_profile(ref),
                                 pre_extension_rolloff_hz=8000.0)
    assert np.all(db[hz > 8000] <= 1e-9)   # cut-only above rolloff
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_match_eq.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/match_eq.py
"""Linear-phase reference match-EQ.

Correction = ref_curve - src_curve, Gaussian-smoothed in octave domain,
capped +-4 dB, zeroed below 30 Hz, cut-only above the source's pre-extension
roll-off (hf_extension owns that region). Applied as 4097-tap windowed-sinc
FIR via fftconvolve, latency compensated.
"""
import numpy as np
from scipy.signal import firwin2, fftconvolve
from scipy.ndimage import gaussian_filter1d
from .reference_engine import THIRD_OCTAVE_HZ, third_octave_spectrum_db

CAP_DB = 4.0
NTAPS = 4097
LOW_ZERO_HZ = 30.0


def correction_curve_db(audio, sr, profile, pre_extension_rolloff_hz=None):
    hz = np.array(profile["third_octave_hz"])
    ref = np.array(profile["third_octave_db"])
    src = np.array(third_octave_spectrum_db(audio, sr))
    corr = ref - src
    corr = gaussian_filter1d(corr, sigma=1.5)        # bands are octave-spaced: sigma in bands
    corr = np.clip(corr, -CAP_DB, CAP_DB)
    corr[hz < LOW_ZERO_HZ] = 0.0
    if pre_extension_rolloff_hz is not None:
        corr[hz > pre_extension_rolloff_hz] = np.minimum(
            corr[hz > pre_extension_rolloff_hz], 0.0)
    return hz, corr


def apply_match_eq(audio, sr, profile, pre_extension_rolloff_hz=None):
    hz, corr = correction_curve_db(audio, sr, profile, pre_extension_rolloff_hz)
    nyq = sr / 2.0
    freqs = np.concatenate(([0.0], hz[hz < nyq], [nyq]))
    gains_db = np.concatenate(([corr[0]], corr[hz < nyq], [corr[hz < nyq][-1]]))
    gains = 10 ** (gains_db / 20.0)
    fir = firwin2(NTAPS, freqs, gains, fs=sr)
    delay = (NTAPS - 1) // 2
    padded = np.pad(audio, ((0, delay), (0, 0)))
    out = fftconvolve(padded, fir[:, None], mode="full", axes=0)
    return out[delay: delay + audio.shape[0]]
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_match_eq.py -v`
Expected: 3 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/match_eq.py
git commit -m "pipeline: linear-phase reference match-EQ"
```

---

### Task 9: `mb_compressor.py` — true multiband compression

**Wave:** 3
**Blocks:** Task 12
**Blocked by:** Task 2

**Files:**
- Create: `tools/automaster_app/pipeline/mb_compressor.py`
- Test: `tmp/tests/test_mb_compressor.py`

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_mb_compressor.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.mb_compressor import compress_multiband

SR = 44100

def _program(seconds=4):
    rng = np.random.default_rng(12)
    n = SR * seconds
    x = rng.standard_normal((n, 2)) * 0.1
    t = np.arange(n) / SR
    x[:, 0] += 0.4 * np.sin(2 * np.pi * 60 * t)      # heavy static bass
    x[:, 1] += 0.4 * np.sin(2 * np.pi * 60 * t)
    env = (np.sin(2 * np.pi * 0.5 * t) > 0).astype(float)
    x += (0.3 * np.sin(2 * np.pi * 8000 * t) * env)[:, None]  # gated HF bursts
    return x

def test_reduces_crest_without_killing_level():
    x = _program()
    y = compress_multiband(x, SR)
    def crest(a):
        return np.max(np.abs(a)) / (np.sqrt(np.mean(a ** 2)) + 1e-12)
    assert crest(y) < crest(x)
    rms_in, rms_out = np.sqrt(np.mean(x ** 2)), np.sqrt(np.mean(y ** 2))
    assert 0.5 < rms_out / rms_in < 2.0

def test_disabled_band_passthrough_flat():
    rng = np.random.default_rng(13)
    x = rng.standard_normal((SR * 2, 2)) * 0.05      # quiet: under all thresholds
    y = compress_multiband(x, SR, max_gr_db=0.0)     # ratios forced to 1:1 effectively
    # flat-sum: spectrum preserved within crossover tolerance
    a = 20*np.log10(np.abs(np.fft.rfft(x[:, 0] * np.hanning(len(x)))) + 1e-12)
    b = 20*np.log10(np.abs(np.fft.rfft(y[:, 0] * np.hanning(len(y)))) + 1e-12)
    sel = slice(40, len(a) // 2)
    assert np.max(np.abs(a[sel] - b[sel])) < 0.3
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_mb_compressor.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/mb_compressor.py
"""4-band multiband compressor: LR4 complementary crossovers at 120/700/6000 Hz,
pedalboard Compressor per band, thresholds auto-calibrated so each band sits at
~1-3 dB GR at program level, makeup from reference band balance (optional).
"""
import numpy as np
from pedalboard import Pedalboard, Compressor
from .filters import lr4_bands, run_pb

CROSSOVERS = [120.0, 700.0, 6000.0]
# (ratio, attack_ms, release_ms) per band: low, low-mid, high-mid, high
BAND_PARAMS = [(2.5, 30.0, 200.0), (2.0, 15.0, 150.0),
               (2.0, 8.0, 120.0), (3.0, 3.0, 80.0)]
TARGET_GR_DB = 2.0


def compress_multiband(audio, sr, ratios=None, makeup_db=None, max_gr_db=None):
    bands = lr4_bands(audio, CROSSOVERS, sr)
    out = np.zeros_like(audio)
    for i, band in enumerate(bands):
        ratio, atk, rel = BAND_PARAMS[i]
        if ratios is not None:
            ratio = ratios[i]
        rms_db = 20 * np.log10(np.sqrt(np.mean(band ** 2)) + 1e-12)
        # threshold set so program RMS sits TARGET_GR_DB*(ratio/(ratio-1)) above it
        thresh = rms_db + 6.0 - TARGET_GR_DB * ratio / max(ratio - 1.0, 0.01)
        if max_gr_db is not None and max_gr_db <= 0.0:
            out += band
            continue
        comp = Pedalboard([Compressor(threshold_db=float(thresh), ratio=float(ratio),
                                      attack_ms=float(atk), release_ms=float(rel))])
        y = run_pb(comp, band, sr)
        # gain-match band so compression reshapes dynamics, not balance
        in_rms = np.sqrt(np.mean(band ** 2)) + 1e-12
        out_rms = np.sqrt(np.mean(y ** 2)) + 1e-12
        y *= in_rms / out_rms
        if makeup_db is not None:
            y *= 10 ** (makeup_db[i] / 20.0)
        out += y
    return out
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_mb_compressor.py -v`
Expected: 2 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/mb_compressor.py
git commit -m "pipeline: 4-band LR4 multiband compressor"
```

---

### Task 10: `stem_chains.py` — per-stem repair chains

**Wave:** 3
**Blocks:** Task 12
**Blocked by:** Task 2, Task 3

**Files:**
- Create: `tools/automaster_app/pipeline/stem_chains.py`
- Test: `tmp/tests/test_stem_chains.py`

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_stem_chains.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.stem_chains import (
    deess, spectral_dereverb, transient_shape, decorrelate,
    phase_locked_sub, process_stems)

SR = 44100

def test_deess_reduces_sibilant_band_only():
    t = np.arange(SR * 2) / SR
    x = (0.2 * np.sin(2 * np.pi * 300 * t) +
         0.5 * np.sin(2 * np.pi * 7000 * t))[:, None] * np.ones((1, 2))
    y = deess(x, SR, threshold_db=-30.0)
    def band(a, lo, hi):
        f = np.abs(np.fft.rfft(a[:, 0])); fr = np.fft.rfftfreq(len(a), 1/SR)
        return np.sum(f[(fr >= lo) & (fr < hi)] ** 2)
    assert band(y, 5000, 9000) < band(x, 5000, 9000) * 0.7
    assert abs(band(y, 100, 1000) / band(x, 100, 1000) - 1.0) < 0.1

def test_dereverb_reduces_tail():
    rng = np.random.default_rng(20)
    n = SR * 3
    dry = np.zeros((n, 2)); dry[: SR // 10] = rng.standard_normal((SR // 10, 2)) * 0.5
    tail = np.exp(-np.arange(n) / (SR * 0.8))[:, None] * rng.standard_normal((n, 2)) * 0.1
    wet = dry + tail
    y = spectral_dereverb(wet, SR)
    tail_region = slice(SR, SR * 2)
    assert np.sqrt(np.mean(y[tail_region] ** 2)) < np.sqrt(np.mean(wet[tail_region] ** 2))

def test_transient_shape_boosts_attack_no_zipper():
    n = SR
    x = np.zeros((n, 2)); x[n // 2: n // 2 + 50] = 0.5
    y = transient_shape(x, SR, attack_boost_db=4.0)
    assert np.max(np.abs(y)) > np.max(np.abs(x)) * 1.1
    d = np.diff(y[:, 0]); assert np.max(np.abs(d)) < 0.6    # no discontinuities

def test_decorrelate_widens_mono_flat_sum():
    rng = np.random.default_rng(21)
    mono = rng.standard_normal((SR, 1)) * 0.2
    x = np.repeat(mono, 2, axis=1)
    y = decorrelate(x, SR)
    corr = np.corrcoef(y[:, 0], y[:, 1])[0, 1]
    assert corr < 0.99
    mono_sum_in = x.mean(axis=1); mono_sum_out = y.mean(axis=1)
    a = 20*np.log10(np.abs(np.fft.rfft(mono_sum_in)) + 1e-12)
    b = 20*np.log10(np.abs(np.fft.rfft(mono_sum_out)) + 1e-12)
    sel = slice(40, len(a)//2)
    assert np.mean(np.abs(a[sel] - b[sel])) < 1.0           # mono-compatible

def test_phase_locked_sub_adds_low_energy():
    t = np.arange(SR * 2) / SR
    x = (0.3 * np.sin(2 * np.pi * 110 * t))[:, None] * np.ones((1, 2))
    y = phase_locked_sub(x, SR, amount=0.5)
    def sub_energy(a):
        f = np.abs(np.fft.rfft(a[:, 0])); fr = np.fft.rfftfreq(len(a), 1/SR)
        return np.sum(f[(fr >= 40) & (fr < 70)] ** 2)
    assert sub_energy(y) > sub_energy(x) * 5

def test_process_stems_light_mode_skips_risky():
    rng = np.random.default_rng(22)
    stems = {k: rng.standard_normal((SR, 2)) * 0.1
             for k in ["drums", "bass", "other", "vocals"]}
    out = process_stems(stems, SR, profile={}, light=True)
    assert set(out) == set(stems)
    for k in out:
        assert out[k].shape == stems[k].shape
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_stem_chains.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/stem_chains.py
"""Per-stem repair chains (Phase A). All chains: stem in -> stem out, same shape.

vocals: deess -> spectral dereverb (conservative) -> presence -> level ride
drums:  lookahead transient shaper -> parallel compression -> OS HF exciter
bass:   mono<100Hz -> OS harmonic saturation -> phase-locked sub
other:  harshness band compression -> decorrelation when fake-stereo
light mode: skip dereverb + decorrelation, halve exciter/saturation amounts.
"""
import numpy as np
import librosa
from scipy.signal import stft, istft, butter, sosfilt, lfilter
from pedalboard import Pedalboard, Compressor, PeakFilter
from .filters import lr4_split, bandpass_lr4, run_pb
from .saturation import oversampled


# ---------- vocals ----------

def deess(audio, sr, threshold_db=-25.0, ratio=4.0):
    band, rest = bandpass_lr4(audio, 5000.0, 9000.0, sr)
    comp = Pedalboard([Compressor(threshold_db=threshold_db, ratio=ratio,
                                  attack_ms=1.0, release_ms=60.0)])
    return rest + run_pb(comp, band, sr)


def spectral_dereverb(audio, sr, max_reduction_db=-12.0):
    """Soft spectral subtraction of the per-bin decile floor (reverb tail)."""
    floor_gain = 10 ** (max_reduction_db / 20.0)
    out = np.empty_like(audio)
    for ch in range(audio.shape[1]):
        f, t, Z = stft(audio[:, ch], fs=sr, nperseg=2048, noverlap=1536)
        mag = np.abs(Z)
        tail = np.quantile(mag, 0.10, axis=1, keepdims=True)   # per-bin tail floor
        sub = np.maximum(mag - 1.5 * tail, mag * floor_gain)
        Zs = sub * np.exp(1j * np.angle(Z))
        _, y = istft(Zs, fs=sr, nperseg=2048, noverlap=1536)
        out[:, ch] = y[: audio.shape[0]]
    return out


def vocal_chain(audio, sr, profile, light=False):
    y = deess(audio, sr)
    if not light:
        y = spectral_dereverb(y, sr)
    presence_db = float(profile.get("vocal_presence_db", 2.0))
    board = Pedalboard([
        PeakFilter(cutoff_frequency_hz=4000.0, gain_db=presence_db, q=0.9),
        Compressor(threshold_db=-24.0, ratio=2.0, attack_ms=25.0, release_ms=250.0),
    ])
    return run_pb(board, y, sr)


# ---------- drums ----------

def transient_shape(audio, sr, attack_boost_db=3.0, lookahead_ms=5.0):
    """Dual-envelope detector, lookahead via signal delay, Hann-smoothed gain."""
    det = np.max(np.abs(audio), axis=1)
    def env(x, ms):
        a = np.exp(-1.0 / (ms / 1000.0 * sr))
        return lfilter([1 - a], [1, -a], x)
    fast, slow = env(det, 1.0), env(det, 80.0)
    trans = np.clip((fast - slow) / (slow + 1e-9), 0.0, 1.0)
    gain = 1.0 + trans * (10 ** (attack_boost_db / 20.0) - 1.0)
    la = int(sr * lookahead_ms / 1000.0)
    win = np.hanning(2 * la + 1); win /= win.sum()
    gain = np.convolve(gain, win, mode="same")
    delayed = np.roll(audio, la, axis=0); delayed[:la] = 0.0
    out = delayed * gain[:, None]
    return np.roll(out, -la, axis=0)


def drum_chain(audio, sr, profile, light=False):
    boost = float(profile.get("transient_boost", 3.0))
    y = transient_shape(audio, sr, attack_boost_db=boost)
    comp = Pedalboard([Compressor(threshold_db=-30.0, ratio=8.0,
                                  attack_ms=1.0, release_ms=100.0)])
    smashed = run_pb(comp, y, sr)
    y = y * 0.7 + smashed * 0.3                       # parallel compression
    amt = 0.15 if light else 0.3
    _, highs = lr4_split(y, 6000.0, sr)
    excited = oversampled(lambda u: np.tanh(u * 3.0), highs, factor=4)
    return y + excited * amt


# ---------- bass ----------

def phase_locked_sub(audio, sr, amount=0.3):
    """Track bass fundamental (pYIN), synth phase-continuous sub one octave down."""
    mono = audio.mean(axis=1)
    hop = 512
    f0, voiced, _ = librosa.pyin(mono.astype(np.float32), fmin=40, fmax=300,
                                 sr=sr, hop_length=hop)
    f0 = np.nan_to_num(f0, nan=0.0)
    f0_s = np.repeat(f0, hop)[: len(mono)]
    voiced_s = np.repeat(np.nan_to_num(voiced, nan=0.0).astype(float), hop)[: len(mono)]
    phase = np.cumsum(2 * np.pi * (f0_s / 2.0) / sr)   # one octave below
    sub = np.sin(phase) * voiced_s
    env = lfilter([1 - 0.999], [1, -0.999], np.abs(mono))
    sub *= env * amount * 4.0
    sos = butter(2, 80.0, btype="low", fs=sr, output="sos")
    sub = sosfilt(sos, sub)
    return audio + sub[:, None]


def bass_chain(audio, sr, profile, light=False):
    from ..modules.imaging import StereoImaging                 # reuse mono-bass
    img = StereoImaging(); img.sample_rate = sr
    y = img.make_mono_bass(audio, cutoff_hz=float(profile.get("mono_cutoff_hz", 100.0)))
    drive = float(profile.get("bass_drive", 1.5)) * (0.5 if light else 1.0)
    sat = oversampled(lambda u: np.tanh(u * drive), y, factor=4)
    y = y * 0.6 + sat * 0.4
    sub_amt = float(profile.get("sub_amount", 0.0))
    if sub_amt > 0:
        y = phase_locked_sub(y, sr, amount=sub_amt)
    return y


# ---------- other ----------

def decorrelate(audio, sr, gain_db=1.5):
    """Complementary alternating bells, interleaved 1/3-oct centers 1-10 kHz.
    L boosts where R cuts -> sums flat in mono."""
    centers = [1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000]
    fl, fr = [], []
    for i, c in enumerate(centers):
        sgn = 1.0 if i % 2 == 0 else -1.0
        fl.append(PeakFilter(cutoff_frequency_hz=float(c), gain_db=sgn * gain_db, q=2.0))
        fr.append(PeakFilter(cutoff_frequency_hz=float(c), gain_db=-sgn * gain_db, q=2.0))
    out = audio.copy()
    out[:, 0] = run_pb(Pedalboard(fl), audio[:, 0], sr)
    out[:, 1] = run_pb(Pedalboard(fr), audio[:, 1], sr)
    return out


def other_chain(audio, sr, profile, light=False):
    band, rest = bandpass_lr4(audio, 2000.0, 5000.0, sr)       # harshness control
    comp = Pedalboard([Compressor(threshold_db=-28.0, ratio=3.0,
                                  attack_ms=3.0, release_ms=120.0)])
    y = rest + run_pb(comp, band, sr)
    if not light:
        corr = np.corrcoef(y[:, 0], y[:, 1])[0, 1] if y.shape[1] == 2 else 0.0
        if corr > 0.95:
            y = decorrelate(y, sr)
    return y


CHAINS = {"vocals": vocal_chain, "drums": drum_chain,
          "bass": bass_chain, "other": other_chain}


def process_stems(stems, sr, profile, light=False):
    return {name: CHAINS[name](audio, sr, profile, light=light)
            for name, audio in stems.items()}
```

Note: `bass_chain` reuses `modules.imaging.StereoImaging.make_mono_bass` — read `tools/automaster_app/modules/imaging.py` first and match its actual constructor/method signature (it may take `sample_rate` in `__init__` or the method may be named differently; adapt the call, keep the reuse).

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_stem_chains.py -v`
Expected: 6 passed (pyin test is slow, ~20 s — fine)

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/stem_chains.py
git commit -m "pipeline: per-stem repair chains"
```

---

### Task 11: `width.py` — frequency-dependent width vs reference

**Wave:** 3
**Blocks:** Task 12
**Blocked by:** Task 2, Task 6

**Files:**
- Create: `tools/automaster_app/pipeline/width.py`
- Test: `tmp/tests/test_width.py`

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_width.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.width import adjust_width
from automaster_app.pipeline.reference_engine import band_width_ratio

SR = 44100

def _stereo_noise(width=1.0, seconds=3, seed=30):
    rng = np.random.default_rng(seed)
    n = SR * seconds
    mid = rng.standard_normal((n,)) * 0.2
    side = rng.standard_normal((n,)) * 0.2 * width
    return np.stack([mid + side, mid - side], axis=1)

def test_narrow_source_widened_toward_ref():
    x = _stereo_noise(width=0.1)
    ref = {"band_width_ratio": {"low": 0.05, "mid": 0.4, "high": 0.5},
           "width_bands_hz": [250, 4000]}
    y = adjust_width(x, SR, ref)
    w_in, w_out = band_width_ratio(x, SR), band_width_ratio(y, SR)
    assert w_out["mid"] > w_in["mid"]
    # capped at +-20% correction
    assert w_out["mid"] <= w_in["mid"] * 1.25 + 0.05

def test_matching_source_untouched():
    x = _stereo_noise(width=1.0)
    w = band_width_ratio(x, SR)
    ref = {"band_width_ratio": w, "width_bands_hz": [250, 4000]}
    y = adjust_width(x, SR, ref)
    assert np.max(np.abs(y - x)) < 0.05
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_width.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/width.py
"""Frequency-dependent stereo width correction toward reference profile.
3 bands (split at profile width_bands_hz), S gain scaled toward target
S/(M+S) ratio, correction capped at +-20%."""
import numpy as np
from .filters import lr4_bands

MAX_CORRECTION = 0.20
BAND_NAMES = ["low", "mid", "high"]


def adjust_width(audio, sr, profile):
    target = profile["band_width_ratio"]
    splits = [float(b) for b in profile.get("width_bands_hz", [250, 4000])]
    mid = (audio[:, 0] + audio[:, 1]) * 0.5
    side = (audio[:, 0] - audio[:, 1]) * 0.5
    ms = np.stack([mid, side], axis=1)
    bands = lr4_bands(ms, splits, sr)
    out_m = np.zeros_like(mid)
    out_s = np.zeros_like(side)
    for name, b in zip(BAND_NAMES, bands):
        em, es = np.mean(b[:, 0] ** 2), np.mean(b[:, 1] ** 2)
        cur = es / (em + es + 1e-12)
        tgt = float(target[name])
        if cur < 1e-6:
            scale = 1.0                       # nothing to widen (pure mono band)
        else:
            # solve gain g for side energy so ratio moves to tgt, then cap
            want = tgt / max(1.0 - tgt, 1e-6) * em
            scale = np.sqrt(want / (es + 1e-12))
            scale = float(np.clip(scale, 1.0 - MAX_CORRECTION, 1.0 + MAX_CORRECTION))
        out_m += b[:, 0]
        out_s += b[:, 1] * scale
    return np.stack([out_m + out_s, out_m - out_s], axis=1)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_width.py -v`
Expected: 2 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/width.py
git commit -m "pipeline: frequency-dependent width vs reference"
```

---

### Task 12: `remixer.py` + `orchestrator.py` — pipeline glue

**Wave:** 4
**Blocks:** Task 13, Task 14
**Blocked by:** Tasks 3, 4, 5, 7, 8, 9, 10, 11

**Files:**
- Create: `tools/automaster_app/pipeline/remixer.py`
- Create: `tools/automaster_app/pipeline/orchestrator.py`
- Test: `tmp/tests/test_orchestrator.py`

- [ ] **Step 1: Write the failing test** (synthetic end-to-end without demucs: stems injected, also bypass path)

```python
# tmp/tests/test_orchestrator.py
import numpy as np, soundfile as sf, sys, os
sys.path.insert(0, "tools")
from automaster_app.pipeline.orchestrator import master_track
from automaster_app.pipeline.remixer import remix
from automaster_app.pipeline.tp_limiter import true_peak_db

SR = 44100

def _song(tmp_path, seconds=6):
    rng = np.random.default_rng(40)
    n = SR * seconds
    t = np.arange(n) / SR
    x = (0.2 * np.sin(2 * np.pi * 110 * t) +
         0.1 * np.sin(2 * np.pi * 880 * t))[:, None] * np.ones((1, 2))
    x += rng.standard_normal((n, 2)) * 0.02
    p = str(tmp_path / "song.wav")
    sf.write(p, x, SR)
    return p

def test_remix_normalizes_headroom():
    rng = np.random.default_rng(41)
    stems = {k: rng.standard_normal((SR, 2)) * 0.4
             for k in ["drums", "bass", "other", "vocals"]}
    y = remix(stems, SR, profile={})
    peak_db = 20 * np.log10(np.max(np.abs(y)))
    assert -7.0 < peak_db < -5.0          # -6 dBFS headroom target

def test_bypass_mode_end_to_end(tmp_path):
    p = _song(tmp_path)
    out_path, report = master_track(p, genre="other", no_stems=True,
                                    out_dir=str(tmp_path), work_dir=str(tmp_path / "wk"))
    y, sr = sf.read(out_path, always_2d=True)
    assert sr == SR
    assert true_peak_db(y, sr) <= -0.9
    assert report["phases"]["A"] == "bypassed"
    assert "output_lufs" in report

def test_stems_injected_end_to_end(tmp_path, monkeypatch):
    p = _song(tmp_path)
    rng = np.random.default_rng(42)
    audio, _ = sf.read(p, always_2d=True)
    fake = {k: audio / 4.0 for k in ["drums", "bass", "other", "vocals"]}
    import automaster_app.pipeline.orchestrator as orch
    monkeypatch.setattr(orch, "_separate",
                        lambda path, progress=None: (fake, SR, "ok"))
    out_path, report = master_track(p, genre="other",
                                    out_dir=str(tmp_path), work_dir=str(tmp_path / "wk"))
    assert report["phases"]["A"] == "ok"
    y, sr = sf.read(out_path, always_2d=True)
    assert true_peak_db(y, sr) <= -0.9
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_orchestrator.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement remixer**

```python
# tools/automaster_app/pipeline/remixer.py
"""Sum processed stems with genre balance trims; peak-normalize to -6 dBFS."""
import numpy as np

HEADROOM_DBFS = -6.0


def remix(stems, sr, profile):
    trims = profile.get("stem_trims_db", {})        # e.g. {"vocals": 1.0}
    n = min(s.shape[0] for s in stems.values())
    mix = np.zeros((n, 2))
    for name, audio in stems.items():
        g = 10 ** (float(trims.get(name, 0.0)) / 20.0)
        mix += audio[:n] * g
    peak = np.max(np.abs(mix)) + 1e-12
    return mix * (10 ** (HEADROOM_DBFS / 20.0) / peak)
```

- [ ] **Step 4: Implement orchestrator**

```python
# tools/automaster_app/pipeline/orchestrator.py
"""Three-phase pipeline orchestrator. Never hard-fails on Phase A:
StemSeparationUnavailable -> bypass (master original mix); degraded
separation -> light mode. Intermediates written to work_dir for debugging."""
import os, json, time
import numpy as np
import soundfile as sf

from .stem_separator import separate as _sep_impl, StemSeparationUnavailable
from .stem_chains import process_stems
from .remixer import remix
from .hf_extension import extend_hf, detect_rolloff
from .reference_engine import load_profile
from .match_eq import apply_match_eq
from .mb_compressor import compress_multiband
from .saturation import glue_saturate
from .width import adjust_width
from .tp_limiter import limit

DEFAULT_TARGETS = {"lufs": -10.0}


def _separate(path, progress=None):           # seam for tests
    return _sep_impl(path, progress=progress)


def master_track(path, genre="other", no_stems=False, light=False,
                 out_dir=".", work_dir=None, profile_dir=None, progress=None):
    t0 = time.time()
    report = {"input": path, "genre": genre, "phases": {}}
    work_dir = work_dir or os.path.join("tmp", "pipeline")
    os.makedirs(work_dir, exist_ok=True)

    ref = (load_profile(genre, profile_dir) if profile_dir
           else load_profile(genre)) or {}
    genre_profile = dict(ref)
    target_lufs = float(ref.get("lufs", DEFAULT_TARGETS["lufs"]))

    audio, sr = sf.read(path, always_2d=True)
    pre_rolloff = detect_rolloff(audio, sr)
    report["pre_extension_rolloff_hz"] = pre_rolloff

    # ---- Phase A ----
    if no_stems:
        pre_master, report["phases"]["A"] = audio, "bypassed"
    else:
        try:
            stems, sr, quality = _separate(path, progress=progress)
            light = light or (quality == "degraded")
            processed = process_stems(stems, sr, genre_profile, light=light)
            pre_master = remix(processed, sr, genre_profile)
            report["phases"]["A"] = quality if not light else "light"
        except StemSeparationUnavailable as e:
            pre_master, report["phases"]["A"] = audio, "bypassed"
            report["phase_a_error"] = str(e)
    pre_master = extend_hf(pre_master, sr, rolloff_hz=pre_rolloff)
    sf.write(os.path.join(work_dir, "premaster.wav"), pre_master, sr)

    # ---- Phase B ----
    x = pre_master
    if ref.get("third_octave_db"):
        x = apply_match_eq(x, sr, ref, pre_extension_rolloff_hz=pre_rolloff)
        report["phases"]["B_match_eq"] = "applied"
    else:
        report["phases"]["B_match_eq"] = "skipped (no profile)"
    x = compress_multiband(x, sr)
    x = glue_saturate(x, amount=float(genre_profile.get("glue_amount", 0.07)))
    if ref.get("band_width_ratio"):
        x = adjust_width(x, sr, ref)
    x, lim_report = limit(x, sr, target_lufs=target_lufs, ceiling_dbtp=-1.0)
    report.update(lim_report)
    report["phases"]["B"] = "ok"

    base = os.path.splitext(os.path.basename(path))[0]
    out_path = os.path.join(out_dir, f"{base}_master.wav")
    sf.write(out_path, x, sr, subtype="PCM_24")
    report["output"] = out_path
    report["elapsed_s"] = round(time.time() - t0, 1)
    with open(os.path.join(work_dir, "report.json"), "w") as fh:
        json.dump(report, fh, indent=1)
    return out_path, report
```

- [ ] **Step 5: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_orchestrator.py -v`
Expected: 3 passed

- [ ] **Step 6: Commit**

```bash
git add tools/automaster_app/pipeline/remixer.py tools/automaster_app/pipeline/orchestrator.py
git commit -m "pipeline: orchestrator + remix bus, light/bypass fallback"
```

---

### Task 13: `verify.py` — Phase C gate

**Wave:** 4
**Blocks:** Task 16
**Blocked by:** Task 6, Task 4

**Files:**
- Create: `tools/automaster_app/pipeline/verify.py`
- Test: `tmp/tests/test_verify.py`

Gate criteria (spec §4, exact):

| key | pass when |
|-----|-----------|
| `spectral_distance_db` | mean ⅓-oct abs deviation ≤ 2.5 AND max single band ≤ 5.0 |
| `true_peak_dbtp` | ≤ −1.0 (+0.1 tolerance) |
| `lufs_delta` | abs ≤ 1.0 |
| `plr_delta` | abs ≤ 2.0 |
| `width_dev` | each band within ±25% of ref ratio (absolute ratio diff ≤ 0.25·ref, floor 0.05) |
| `hf_sharpness_db` | energy 10–16 kHz within ±3 dB of ref (LUFS-matched) |
| `sibilance_ratio` | ≤ ref × 1.2 |

- [ ] **Step 1: Write the failing test**

```python
# tmp/tests/test_verify.py
import numpy as np, sys
sys.path.insert(0, "tools")
from automaster_app.pipeline.verify import verify_master
from automaster_app.pipeline.reference_engine import build_profile
import soundfile as sf

SR = 44100

def _master_like(seed=50, tilt=-3.0, seconds=5):
    rng = np.random.default_rng(seed)
    n = SR * seconds
    x = rng.standard_normal((n, 2)) * 0.2
    f = np.fft.rfft(x, axis=0)
    freqs = np.fft.rfftfreq(n, 1 / SR)
    f *= ((np.maximum(freqs, 20) / 1000.0) ** (tilt / 6.020))[:, None]
    x = np.fft.irfft(f, n=n, axis=0)
    return x / np.max(np.abs(x)) / 1.5

def test_self_reference_passes(tmp_path):
    x = _master_like()
    p = str(tmp_path / "ref.wav"); sf.write(p, x, SR)
    prof = build_profile([p], "g")
    result = verify_master(x, SR, prof)
    assert result["pass"] is True, result["checks"]

def test_wrong_tilt_fails_spectral(tmp_path):
    ref = _master_like(tilt=0.0)
    p = str(tmp_path / "ref.wav"); sf.write(p, ref, SR)
    prof = build_profile([p], "g")
    cand = _master_like(seed=51, tilt=-9.0)
    result = verify_master(cand, SR, prof)
    assert result["checks"]["spectral_distance_db"]["pass"] is False

def test_result_shape():
    x = _master_like()
    import tempfile, os
    with tempfile.TemporaryDirectory() as td:
        p = os.path.join(td, "r.wav"); sf.write(p, x, SR)
        prof = build_profile([p], "g")
    r = verify_master(x, SR, prof)
    for k in ["spectral_distance_db", "true_peak_dbtp", "lufs_delta",
              "plr_delta", "width_dev", "hf_sharpness_db", "sibilance_ratio"]:
        assert k in r["checks"] and "pass" in r["checks"][k] and "value" in r["checks"][k]
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tmp/tests/test_verify.py -v`
Expected: FAIL — module not found

- [ ] **Step 3: Implement**

```python
# tools/automaster_app/pipeline/verify.py
"""Phase C verification gate: LUFS-matched metric comparison vs genre profile.
The gate is the project's definition of done."""
import numpy as np
import pyloudnorm as pyln
from .reference_engine import (third_octave_spectrum_db, band_width_ratio,
                               THIRD_OCTAVE_HZ)
from .tp_limiter import true_peak_db


def _band_energy_db(audio, sr, lo, hi):
    mono = audio.mean(axis=1)
    f = np.abs(np.fft.rfft(mono))
    fr = np.fft.rfftfreq(len(mono), 1 / sr)
    sel = f[(fr >= lo) & (fr < hi)]
    return 20 * np.log10(np.sqrt(np.mean(sel ** 2)) + 1e-12)


def _sibilance_ratio(audio, sr):
    e_sib = 10 ** (_band_energy_db(audio, sr, 4000, 8000) / 10.0)
    e_mid = 10 ** (_band_energy_db(audio, sr, 1000, 4000) / 10.0)
    return e_sib / (e_mid + 1e-12)


def verify_master(audio, sr, profile):
    meter = pyln.Meter(sr)
    lufs = meter.integrated_loudness(audio)
    norm = audio * 10 ** ((-14.0 - lufs) / 20.0)      # LUFS-matched analysis copy

    checks = {}
    spec = np.array(third_octave_spectrum_db(norm, sr))
    ref_spec = np.array(profile["third_octave_db"])
    hz = np.array(THIRD_OCTAVE_HZ)
    sel = (hz >= 50) & (hz <= 16000)
    dev = np.abs(spec[sel] - ref_spec[sel])
    checks["spectral_distance_db"] = {
        "value": float(np.mean(dev)),
        "max_band": float(np.max(dev)),
        "pass": bool(np.mean(dev) <= 2.5 and np.max(dev) <= 5.0)}

    tp = true_peak_db(audio, sr)
    checks["true_peak_dbtp"] = {"value": float(tp), "pass": bool(tp <= -0.9)}

    d = lufs - float(profile["lufs"])
    checks["lufs_delta"] = {"value": float(d), "pass": bool(abs(d) <= 1.0)}

    peak_db = 20 * np.log10(np.max(np.abs(audio)) + 1e-12)
    plr_d = (peak_db - lufs) - float(profile["plr"])
    checks["plr_delta"] = {"value": float(plr_d), "pass": bool(abs(plr_d) <= 2.0)}

    w = band_width_ratio(audio, sr)
    ref_w = profile["band_width_ratio"]
    devs = {k: abs(w[k] - ref_w[k]) for k in w}
    ok = all(devs[k] <= max(0.25 * ref_w[k], 0.05) for k in devs)
    checks["width_dev"] = {"value": devs, "pass": bool(ok)}

    # HF sharpness vs ref needs a ref signal energy: use profile spectrum bands 10-16k
    cand_hf = float(np.mean(spec[(hz >= 10000) & (hz <= 16000)]))
    ref_hf = float(np.mean(ref_spec[(hz >= 10000) & (hz <= 16000)]))
    checks["hf_sharpness_db"] = {"value": cand_hf - ref_hf,
                                 "pass": bool(abs(cand_hf - ref_hf) <= 3.0)}

    sib = _sibilance_ratio(norm, sr)
    # store ref sibilance in profile when available; else compare to spectrum-derived bound
    ref_sib = profile.get("sibilance_ratio")
    if ref_sib is None:
        e_sib = 10 ** (np.mean(ref_spec[(hz >= 4000) & (hz <= 8000)]) / 10.0)
        e_mid = 10 ** (np.mean(ref_spec[(hz >= 1000) & (hz <= 4000)]) / 10.0)
        ref_sib = e_sib / (e_mid + 1e-12)
    checks["sibilance_ratio"] = {"value": float(sib),
                                 "pass": bool(sib <= ref_sib * 1.2)}

    return {"pass": bool(all(c["pass"] for c in checks.values())), "checks": checks}


def export_ab_snippet(master, reference_audio, sr, out_path, seconds=20):
    """Loudness-matched interleaved A/B: 5 s ref / 5 s master alternating."""
    import soundfile as sf
    meter = pyln.Meter(sr)
    def norm(x):
        return x * 10 ** ((-14.0 - meter.integrated_loudness(x)) / 20.0)
    m, r = norm(master), norm(reference_audio)
    seg = sr * 5
    n = min(len(m), len(r), sr * seconds)
    start = max(0, (min(len(m), len(r)) - n) // 2)
    chunks = []
    for i in range(start, start + n, seg):
        chunks.append(r[i:i + seg]); chunks.append(m[i:i + seg])
    sf.write(out_path, np.concatenate(chunks)[: sr * seconds * 2], sr)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `python -m pytest tmp/tests/test_verify.py -v`
Expected: 3 passed

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/pipeline/verify.py
git commit -m "pipeline: Phase C verification gate + A/B export"
```

---

### Task 14: CLI integration — `main.py` + `worker_pro.py`

**Wave:** 5
**Blocks:** Task 16
**Blocked by:** Task 12, Task 13

**Files:**
- Modify: `tools/automaster_app/main.py` (argparse block + dispatch)
- Modify: `tools/automaster_app/worker_pro.py` (route to new pipeline)
- Test: manual CLI smoke (steps below)

- [ ] **Step 1: Read both files end-to-end.** Locate the argparse/option definitions in `main.py` and the entry point where `worker_pro.py` invokes the old `processor_pro` chain.

- [ ] **Step 2: Add flags to `main.py`**

Add to existing argparse group (match its style exactly):

```python
parser.add_argument("--no-stems", action="store_true",
                    help="Skip Phase A stem separation; master the original mix")
parser.add_argument("--light", action="store_true",
                    help="Light mode: reduced per-stem processing depth")
parser.add_argument("--refs", type=str, default=None,
                    help="Directory of reference tracks (refs/<genre>/*.wav); "
                         "builds/updates profiles before mastering")
parser.add_argument("--legacy-chain", action="store_true",
                    help="Use the old processor_pro chain instead of the new pipeline")
```

Dispatch: when `--refs` given, call `pipeline.reference_engine.build_reference_profiles(args.refs)` before processing and print built genres. Default path (no `--legacy-chain`) routes each input file to `pipeline.orchestrator.master_track(path, genre=args.genre, no_stems=args.no_stems, light=args.light)`. `--legacy-chain` preserves the old behavior untouched.

- [ ] **Step 3: Route `worker_pro.py`**

In the worker's per-file processing function, replace the old chain invocation with `master_track(...)`, forwarding the existing progress callback as `progress=`. Print/emit the same progress events the rich UI expects (read the surrounding code; the orchestrator's phases map to coarse progress: A=0–60%, B=60–95%, write=95–100%). Keep the legacy code path behind the `--legacy-chain` flag.

Honest labeling (spec §5): wherever report strings say "AI Generated Master" or similar, change to `"Rule-based mastering + neural stem separation"` — grep: `grep -rn "AI" tools/automaster_app/reports_generator.py tools/automaster_app/worker_pro.py`.

- [ ] **Step 4: Smoke test both paths**

```bash
python3 - <<'EOF'
import numpy as np, soundfile as sf
sr = 44100; t = np.arange(sr*8)/sr
x = (0.3*np.sin(2*np.pi*110*t) + 0.05*np.random.default_rng(1).standard_normal(sr*8))
sf.write("tmp/smoke.wav", np.stack([x, x], 1) * 0.5, sr)
EOF
cd tools && python -m automaster_app.main ../tmp/smoke.wav --no-stems 2>&1 | tail -5
```
Expected: completes, prints output path, no traceback. Then full-stem path (slow, demucs runs ~2-5 min):
```bash
cd tools && python -m automaster_app.main ../tmp/smoke.wav 2>&1 | tail -5
```
Expected: completes; `tmp/stems/<hash>/` contains 4 wavs; second run is fast (cache hit).

- [ ] **Step 5: Commit**

```bash
git add tools/automaster_app/main.py tools/automaster_app/worker_pro.py
git commit -m "cli: route to three-phase pipeline, --no-stems/--light/--refs/--legacy-chain"
```

---

### Task 15: Retire/reroute old modules

**Wave:** 5
**Blocks:** Task 16
**Blocked by:** Task 3, Task 4

**Files:**
- Modify: `tools/automaster_app/modules/restoration.py` (delete stub functions)
- Modify: `tools/automaster_app/modules/vintage.py`, `modules/exciter.py`, `modules/harmonics.py` (route nonlinearities through `saturation.oversampled`)
- Modify: `tools/automaster_app/processor_pro.py` (remove stub stages from chain; old limiter only behind legacy flag)
- Modify: `tools/automaster_app/template.py` (drop dereverb/deesser entries)

- [ ] **Step 1: Read `processor_pro.py`, `template.py`, `modules/restoration.py` end-to-end.**

- [ ] **Step 2: Delete the lie.** Remove dereverb/deesser stub functions from `restoration.py` and their stages from `template.py` + `processor_pro.py` dispatch. If `restoration.py` becomes empty, delete the file and its imports. The real de-esser/de-reverb live in `pipeline/stem_chains.py` (vocal chain).

- [ ] **Step 3: Reroute nonlinearities.** In `vintage.py`, `exciter.py`, `harmonics.py`: wrap each nonlinear transfer (`tanh`, `arctan`, `x**2`, `x**3`, `sign(x)*|x|**0.5`) with `from ..pipeline.saturation import oversampled` — e.g. `processed = oversampled(lambda u: np.tanh(u * 1.1) * 0.95, audio)` replacing the direct call. Keep each module's existing pre/post filters and blend logic intact (the 18 kHz post-filter in vintage.py may stay; it is now belt-and-suspenders).

- [ ] **Step 4: Legacy limiter.** In `processor_pro.py`, replace the `final_limiting` stage call so the default path uses `pipeline.tp_limiter.limit(...)` and only `--legacy-chain` (flag already plumbed in Task 14; check the flag's plumbing reaches processor_pro — if not, add a `use_legacy_limiter` ctor param defaulting False).

- [ ] **Step 5: Verify nothing imports deleted names**

```bash
grep -rn "dereverb\|deesser\|restoration" tools/automaster_app/ --include="*.py" | grep -v pipeline/
```
Expected: no live imports of deleted stubs (template/processor references removed). Run the existing unit tests that still apply: `python -m pytest tmp/tests -v` → all pass.

- [ ] **Step 6: Commit**

```bash
git add tools/automaster_app/modules/ tools/automaster_app/processor_pro.py tools/automaster_app/template.py
git commit -m "retire stub modules, oversample legacy nonlinearities, default to tp_limiter"
```

---

### Task 16: Integration corpus + `evaluate_library.py` gate

**Wave:** 6
**Blocks:** —
**Blocked by:** Task 14, Task 15

**Files:**
- Modify: `tools/evaluate_library.py` (rework around verify gate)
- Test: corpus run (below)

- [ ] **Step 1: Read `tools/evaluate_library.py` end-to-end.** Keep its file-discovery logic; replace its scoring with the Phase C gate.

- [ ] **Step 2: Rework evaluate_library around the gate**

New behavior: for each track in the library dir (existing discovery), run `master_track()`, then `verify_master()` against the genre profile (genre from existing per-track logic or `--genre` flag), write `tmp/evaluation/results.csv` with columns: `file, genre, phase_a, elapsed_s, output_lufs, true_peak_dbtp, spectral_distance_db, pass, failed_checks`. Print a rich summary table (match existing console style). Also call `export_ab_snippet()` per track when a reference wav exists for the genre, writing `<name>_AB.wav` next to the master.

- [ ] **Step 3: Build reference profiles**

If `refs/<genre>/` dirs contain wavs, run `build_reference_profiles("refs")`. If the user has not yet supplied references, generate the fallback: for each genre in the existing genre library, synthesize the built-in target curve profile (spec §3.1) — implement `reference_engine.builtin_profile(genre, genre_dna)` that maps the existing `eq_emphasis`/`loudness_target` from `get_genre_profile()` onto the profile JSON schema (third_octave_db from a −3 dB/oct pink reference shaped by eq_emphasis; lufs from loudness_target; plr 8.0; width {low:.05, mid:.3, high:.45}). Write these to the profile dir for any genre lacking a real-refs profile.

- [ ] **Step 4: Corpus run (the actual gate)**

Pick 3 tracks from the user's Suno library (locate it: `grep -rn "library\|input_dir" tools/evaluate_library.py` reveals the configured path; otherwise ask the directory from existing config/main defaults — do NOT guess a random folder). Run:

```bash
cd tools && python evaluate_library.py --limit 3 2>&1 | tail -30
```
Expected: 3 masters produced, < 10 min/track, results.csv written, gate table printed. Investigate any `pass=False`: spectral failures → check match_eq applied; TP failures → tp_limiter bug (fix before proceeding).

- [ ] **Step 5: Regression snapshot**

```bash
cp tmp/evaluation/results.csv tmp/evaluation/baseline.csv
```
Future runs compare against baseline: any metric drifting > 1 dB fails review.

- [ ] **Step 6: Commit**

```bash
git add tools/evaluate_library.py tools/automaster_app/pipeline/reference_engine.py
git commit -m "evaluate_library: Phase C gate + builtin fallback profiles"
```

---

## Self-Review (completed)

1. **Spec coverage:** Phase A (T7 separation+gate, T10 chains, T5 HF ext, T12 remix/light/bypass) ✅; Phase B (T8 match-EQ, T9 multiband, T3 glue, T11 width, T4 limiter+dither) ✅; Phase C (T13 gate, T16 corpus+AB) ✅; orchestration/CLI (T12, T14) ✅; module retirement table §3.7 (T15) ✅; builtin fallback profiles §3.1 (T16 step 3) ✅; honest labeling §5 (T14 step 3) ✅.
2. **Placeholder scan:** no TBDs; all code steps carry complete code. Two intentional read-first adaptations flagged explicitly (T10 imaging reuse note, T14/T15 plumbing) — these instruct reading the real file, not guessing.
3. **Type consistency:** profile JSON keys (`third_octave_hz/db`, `band_width_ratio`, `lufs`, `plr`, `width_bands_hz`) consistent across T6/T8/T11/T13/T16; `limit()` returns `(audio, report)` consumed in T12/T13 tests; `run_pb`/`lr4_*` signatures consistent T2→T9/T10/T11.
4. **Wave plan:** all tasks have Wave/Blocks/Blocked-by; same-wave file sets disjoint (checked pairwise); semantic deps respected (T8/T11 after T6; T9/T10 after T2/T3; T12 after wave 2-3; T14/T15 disjoint files; T16 last).
