# SongMatch — Design Document

## Overview

SongMatch is a Python CLI tool that performs automated EQ matching between a user's audio and a reference track. It supports two complementary matching modes that can be used independently or combined:

- **`--stems-match`**: Analyzes the reference to find its loudest segment (typically the chorus), splits both tracks into stems, applies per-stem spectral EQ matching, and recombines into a new stereo WAV.
- **`--mix-match`**: Performs whole-mix EQ matching via matchering (frequency response, RMS, stereo width, peak amplitude, limiting). Uses the full reference audio without cropping.

When both flags are used together, `--stems-match` runs first, producing a recombined file, and then `--mix-match` runs on that recombined output. At least one flag must be specified.

---

## Table of Contents

- [SongMatch — Design Document](#songmatch--design-document)
  - [Overview](#overview)
  - [Table of Contents](#table-of-contents)
  - [Architecture](#architecture)
  - [Mode Selection and Execution Order](#mode-selection-and-execution-order)
  - [Pipeline: --stems-match](#pipeline---stems-match)
    - [Step-by-step:](#step-by-step)
  - [Pipeline: --mix-match](#pipeline---mix-match)
    - [Step-by-step:](#step-by-step-1)
  - [Pipeline: Combined (--stems-match --mix-match)](#pipeline-combined---stems-match---mix-match)
  - [Module Structure](#module-structure)
    - [`cli.py`](#clipy)
    - [`config.py`](#configpy)
    - [`pipeline.py`](#pipelinepy)
    - [`loudness.py`](#loudnesspy)
    - [`separation.py`](#separationpy)
    - [`eq_match.py`](#eq_matchpy)
      - [Algorithm Details](#algorithm-details)
    - [`mix_match.py`](#mix_matchpy)
    - [`audio_io.py`](#audio_iopy)
    - [`utils.py`](#utilspy)
  - [CLI Interface](#cli-interface)
    - [Validation Rules](#validation-rules)
    - [Usage Examples](#usage-examples)
  - [Loudest Segment Detection](#loudest-segment-detection)
    - [Algorithm](#algorithm)
    - [Edge Cases](#edge-cases)
  - [Stem Separation](#stem-separation)
    - [Implementation](#implementation)
    - [Notes](#notes)
    - [User Stems Storage](#user-stems-storage)
  - [Per-Stem Spectral EQ Matching (Custom)](#per-stem-spectral-eq-matching-custom)
    - [Algorithm](#algorithm-1)
    - [Smoothing Detail](#smoothing-detail)
  - [Stem Recombination](#stem-recombination)
  - [File Naming and Output Conventions](#file-naming-and-output-conventions)
    - [--stems-match only:](#--stems-match-only)
    - [--mix-match only:](#--mix-match-only)
    - [--stems-match --mix-match (combined):](#--stems-match---mix-match-combined)
    - [Postfix convention:](#postfix-convention)
  - [Dependencies](#dependencies)
    - [Runtime](#runtime)
    - [System](#system)
    - [Development](#development)
  - [Configuration Defaults](#configuration-defaults)
  - [Error Handling](#error-handling)
    - [Fail-fast conditions (raise and exit):](#fail-fast-conditions-raise-and-exit)
    - [Graceful degradation:](#graceful-degradation)
    - [Logging:](#logging)
  - [Testing Strategy](#testing-strategy)
    - [Unit tests:](#unit-tests)
    - [Integration tests:](#integration-tests)
    - [Test fixtures:](#test-fixtures)
  - [Future Considerations (Not in Scope for v1)](#future-considerations-not-in-scope-for-v1)

---

## Architecture

```
songmatch/
├── __init__.py
├── __main__.py              # entry point: `python -m songmatch`
├── cli.py                   # typer CLI definition
├── config.py                # dataclass for runtime config / defaults
├── pipeline.py              # top-level orchestration (stem path + mix-match path)
├── loudness.py              # LUFS envelope, segment detection, beat-aligned cropping
├── separation.py            # stem splitting via audio-separator
├── eq_match.py              # custom spectral EQ matching (scipy-based)
├── mix_match.py             # matchering wrapper for --mix-match mode
├── audio_io.py              # load/save helpers, format conversion, temp file management
└── utils.py                 # logging, path helpers, postfix naming
```

All heavy lifting is in dedicated modules. `pipeline.py` orchestrates the two modes and calls into the other modules. No module should import from `cli.py` — dependency flows one way: `cli → pipeline → everything else`.

---

## Mode Selection and Execution Order

At least one of `--stems-match` or `--mix-match` must be specified. If neither is given, the CLI exits with an error.

| Flags | Behavior |
|---|---|
| `--stems-match` only | Per-stem EQ matching. Output: `_stemsmatched.wav` |
| `--mix-match` only | Whole-mix matchering. Uses full reference (no cropping). Output: `_mixmatched.wav` |
| `--stems-match --mix-match` | Stems-match runs first → produces `_stemsmatched.wav`. Then mix-match runs on that file as its input (with the original reference) → produces `_mixmatched.wav`. Both output files are kept. |

When combined, the execution order is always: **stems-match first, then mix-match**. The mix-match step uses the stems-match output as its TARGET, not the original user audio. This ensures the per-stem tonal corrections are preserved and the mix-match layer adds overall loudness/width/FR polish on top.

---

## Pipeline: --stems-match

Per-stem EQ matching path. Only runs when `--stems-match` is set.

### Step-by-step:

1. **Load and validate inputs**
   - Verify user audio and reference audio files exist and are readable audio formats.
   - Load both as float32 numpy arrays at their native sample rate via `soundfile`.

2. **Loudest segment detection** (reference audio only)
   - Compute a short-term LUFS envelope over the reference.
   - Find the longest contiguous region above a threshold (peak LUFS minus `--loudness-threshold` dB).
   - If the reference's dynamic range (peak LUFS − min LUFS) is below `--dynamic-range-floor`, skip cropping (use full reference or a centered chunk capped at `--max-segment`).
   - Snap crop boundaries to nearest detected beats via librosa.
   - Write the cropped segment to a temp WAV file.
   - See [Loudest Segment Detection](#loudest-segment-detection) for full algorithm.

3. **Stem separation — reference (temp cropped file)**
   - Run `audio-separator` on the temp cropped reference audio.
   - Produces 4 stems: `vocals`, `drums`, `bass`, `other`.
   - Store as temp files. These are deleted at program exit unless `--keep-ref-stems` is set.

4. **Stem separation — user audio**
   - Run `audio-separator` on the full user audio.
   - Produces 4 stems: `vocals`, `drums`, `bass`, `other`.
   - Store permanently alongside the user audio file. See [File Naming](#file-naming-and-output-conventions) for naming.

5. **Per-stem EQ matching**
   - For each of the 4 stem pairs (user stem, reference stem):
     - Compute PSD of both via Welch's method.
     - Derive a smoothed transfer function (magnitude ratio).
     - Apply smoothing controlled by `--eq-smoothing`.
     - Clamp gain to `--eq-max-gain`.
     - Convert to minimum-phase FIR filter.
     - Convolve with user stem.
   - **Overwrite** the user stem file with the EQ-matched version.
   - See [Per-Stem Spectral EQ Matching](#per-stem-spectral-eq-matching-custom) for full algorithm.

6. **Recombine stems**
   - Sum all 4 EQ-matched user stems (element-wise addition of float32 arrays).
   - Normalize if any samples exceed ±1.0 (peak normalize to -0.1 dBFS to avoid clipping).
   - Convert to target sample rate and bit depth (`--output-sr`, `--output-bits`).
   - Write as `<name>_stemsmatched.wav`. See [File Naming](#file-naming-and-output-conventions).

7. **Cleanup**
   - Delete temp cropped reference file.
   - Delete temp reference stems (unless `--keep-ref-stems`).
   - User stems and recombined output are kept.

8. **Return** the path to `_stemsmatched.wav` (used as input to mix-match if both flags are set).

---

## Pipeline: --mix-match

Whole-mix EQ matching via matchering. Only runs when `--mix-match` is set.

### Step-by-step:

1. **Determine input audio**
   - If `--stems-match` was also set: use the `_stemsmatched.wav` output from the stems-match step as TARGET.
   - If `--mix-match` only: use the original user audio as TARGET.

2. **Run matchering**
   - Use the `matchering` Python library directly.
   - Pass TARGET and the **full, uncropped** reference audio as REFERENCE.
   - Matchering handles: frequency response matching, RMS matching, stereo width matching, peak amplitude matching, and brick-wall limiting.
   - Use matchering's `Config` to set output format (44.1kHz 16-bit WAV by default, configurable via `--output-sr` and `--output-bits`).
   - Use `matchering.Result` to write output.

3. **Output**
   - Write as `<name>_mixmatched.wav`. See [File Naming](#file-naming-and-output-conventions).

---

## Pipeline: Combined (--stems-match --mix-match)

When both flags are set, the pipeline runs sequentially:

1. **Stems-match pipeline** (steps 1–8 above) → produces `my_song_stemsmatched.wav`
2. **Mix-match pipeline** (steps 1–3 above) → uses `my_song_stemsmatched.wav` as TARGET → produces `my_song_mixmatched.wav`

Both output files are kept. The user gets:
- `my_song_stemsmatched.wav` — per-stem EQ matched only
- `my_song_mixmatched.wav` — per-stem EQ matched + full-mix mastering polish
- `my_song_stems/` — the EQ-matched stems (always from stems-match)

---

## Module Structure

### `cli.py`

Typer application with a single command `process`.

```python
import typer
app = typer.Typer()

@app.command()
def process(
    user_audio: Path,
    reference_audio: Path,
    stems_match: bool = False,
    mix_match: bool = False,
    eq_smoothing: float = 0.33,
    eq_max_gain: float = 12.0,
    loudness_threshold: float = 2.0,
    min_segment: float = 5.0,
    max_segment: float = 30.0,
    dynamic_range_floor: float = 4.0,
    model: str = "model_bs_roformer_ep_317_sdr_12.9755.ckpt",
    output_sr: int = 44100,
    output_bits: int = 16,
    keep_ref_stems: bool = False,
): ...
```

**Validation:** If neither `--stems-match` nor `--mix-match` is set, exit with an error message: `"At least one of --stems-match or --mix-match must be specified."`

All parameters are passed into a `SongMatchConfig` dataclass and forwarded to `pipeline.run()`.

### `config.py`

```python
from dataclasses import dataclass
from pathlib import Path

@dataclass
class SongMatchConfig:
    user_audio: Path
    reference_audio: Path
    stems_match: bool = False
    mix_match: bool = False
    eq_smoothing: float = 0.33        # octave fractions, 0 = no smoothing
    eq_max_gain: float = 12.0         # dB, max boost/cut clamp
    loudness_threshold: float = 2.0   # dB below peak LUFS
    min_segment: float = 5.0          # seconds
    max_segment: float = 30.0         # seconds
    dynamic_range_floor: float = 4.0  # dB LRA below which skip cropping
    model: str = "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
    output_sr: int = 44100
    output_bits: int = 16
    keep_ref_stems: bool = False
```

### `pipeline.py`

Top-level orchestration. Handles three cases:

```python
def run(config: SongMatchConfig) -> list[Path]:
    """Returns list of output file paths produced."""
    outputs = []

    # Determine the input for mix-match (original user audio or stems-match output)
    mix_match_input = config.user_audio

    if config.stems_match:
        stems_matched_path = _run_stems_match(config)
        outputs.append(stems_matched_path)
        mix_match_input = stems_matched_path  # chain into mix-match

    if config.mix_match:
        mix_matched_path = _run_mix_match(config, target_audio=mix_match_input)
        outputs.append(mix_matched_path)

    return outputs
```

`_run_stems_match` implements the stems-match pipeline (loudness detection → stem splitting → per-stem EQ → recombination).
`_run_mix_match` implements the mix-match pipeline (matchering). Its `target_audio` parameter determines what it processes — either the original user audio or the stems-match output.

### `loudness.py`

Exports:

- `compute_lufs_envelope(audio: np.ndarray, sr: int, window_sec: float = 3.0, hop_sec: float = 0.1) -> np.ndarray`
  Returns array of LUFS values over time.

- `find_loudest_segment(envelope: np.ndarray, hop_sec: float, config: SongMatchConfig) -> tuple[float, float] | None`
  Returns `(start_sec, end_sec)` of the loudest segment, or `None` if cropping should be skipped (flat track).

- `snap_to_beats(start_sec: float, end_sec: float, audio: np.ndarray, sr: int) -> tuple[float, float]`
  Adjusts boundaries to nearest beat positions via librosa.

- `crop_reference(audio: np.ndarray, sr: int, config: SongMatchConfig) -> tuple[np.ndarray, int]`
  High-level function that combines all the above. Returns cropped audio array and sample rate.

### `separation.py`

Exports:

- `separate_stems(audio_path: Path, output_dir: Path, model: str) -> dict[str, Path]`
  Runs audio-separator on the given file. Returns a dict mapping stem name to output file path, e.g. `{"vocals": Path(...), "drums": Path(...), "bass": Path(...), "other": Path(...)}`.

Implementation notes:
- Use `audio_separator.separator.Separator` class.
- Set `output_dir` explicitly.
- Set `output_format` to `WAV`.
- Load the model specified by `config.model`.
- Handle model auto-download (audio-separator does this internally).

### `eq_match.py`

Exports:

- `compute_psd(audio: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarray]`
  Returns `(frequencies, psd)` using `scipy.signal.welch`.

- `compute_transfer_function(psd_ref: np.ndarray, psd_user: np.ndarray, freqs: np.ndarray, smoothing_octaves: float, max_gain_db: float) -> np.ndarray`
  Returns smoothed, clamped magnitude transfer function.

- `apply_eq(audio: np.ndarray, sr: int, transfer_function: np.ndarray, freqs: np.ndarray) -> np.ndarray`
  Converts transfer function to minimum-phase FIR filter and convolves with audio. Returns EQ'd audio.

- `match_eq(user_stem: np.ndarray, ref_stem: np.ndarray, sr: int, smoothing_octaves: float, max_gain_db: float) -> np.ndarray`
  High-level convenience function combining the above three steps.

#### Algorithm Details

**PSD Computation:**
- Use `scipy.signal.welch` with `nperseg=8192` (good frequency resolution for music at 44.1kHz).
- Use Hann window (default).
- Both audio arrays should be the same sample rate before PSD comparison. Resample if needed.

**Transfer Function:**
- `H(f) = sqrt(PSD_ref(f) / PSD_user(f))` — square root because PSD is power, and we want amplitude ratio.
- Smoothing: apply a moving average on the log-frequency axis. The window width in bins is determined by the `smoothing_octaves` parameter. Convert octave width to bin count based on frequency resolution. A 1/3-octave window at 1kHz covers ~890Hz to ~1120Hz. Use `scipy.ndimage.uniform_filter1d` on the log-magnitude transfer function.
- Clamping: `np.clip(20 * np.log10(H), -max_gain_db, +max_gain_db)`, then convert back to linear.
- Set DC (0 Hz) and Nyquist gains to 0 dB (unity) to avoid DC offset and aliasing issues.

**FIR Filter Design:**
- Use `scipy.signal.firwin2` to create a FIR filter from the (frequency, gain) pairs.
- Filter order: 4095 (a good balance for music audio — gives ~0.5 Hz resolution at 44.1kHz with manageable latency). Must be odd for firwin2 with type I filter.
- Apply using `scipy.signal.fftconvolve` with `mode='same'` to preserve length.

**Stereo handling:**
- Stems may be stereo (2 channels). Process each channel independently with the same transfer function, or compute PSD by averaging both channels' PSDs, derive one transfer function, and apply to both channels. The latter is simpler and avoids channel-dependent EQ artifacts. **Use the averaged-PSD approach.**

### `mix_match.py`

Exports:

- `run_matchering(target_audio: Path, reference_audio: Path, output_path: Path, config: SongMatchConfig) -> Path`

Implementation:
```python
import matchering as mg

def run_matchering(target_audio, reference_audio, output_path, config):
    mg.process(
        target=str(target_audio),
        reference=str(reference_audio),
        results=[
            mg.pcm16(str(output_path))   # or pcm24/pcm32 based on config.output_bits
        ]
    )
    return output_path
```

The `target_audio` parameter is either the original user audio (when `--mix-match` only) or the `_stemsmatched.wav` output (when both flags are set). This is determined by `pipeline.py`, not by this module.

Select `mg.pcm16`, `mg.pcm24`, or `mg.pcm32` based on `config.output_bits`. Matchering handles sample rate internally.

### `audio_io.py`

Exports:

- `load_audio(path: Path) -> tuple[np.ndarray, int]` — loads via soundfile, returns (data, sr).
- `save_audio(path: Path, audio: np.ndarray, sr: int, bits: int)` — saves WAV with specified bit depth.
- `resample(audio: np.ndarray, sr_orig: int, sr_target: int) -> np.ndarray` — resamples via librosa if needed.
- `create_temp_wav(audio: np.ndarray, sr: int) -> Path` — writes to a temp file, returns path. Caller is responsible for cleanup.

### `utils.py`

Exports:

- `make_output_path(input_path: Path, postfix: str, extension: str = ".wav") -> Path`
  e.g. `make_output_path(Path("song.wav"), "eqmatched")` → `Path("song_eqmatched.wav")`

- `make_stem_dir(user_audio: Path) -> Path`
  Creates and returns a directory for stems alongside the user audio.
  e.g. for `song.wav` → creates `song_stems/` in the same directory.

- `setup_logging()` — configure Python logging for the application.

---

## CLI Interface

```
Usage: songmatch process [OPTIONS] USER_AUDIO REFERENCE_AUDIO

Arguments:
  USER_AUDIO        Path to the user's audio file (WAV, FLAC, MP3)
  REFERENCE_AUDIO   Path to the reference audio file (WAV, FLAC, MP3)

Options:
  --stems-match                 Per-stem EQ matching. Splits both tracks
                                into stems, matches EQ per stem, recombines.
                                At least one of --stems-match or --mix-match
                                must be specified.
  --mix-match                   Full-mix EQ match via matchering. Uses full
                                reference audio. If combined with
                                --stems-match, runs after it on the
                                recombined output.
  --eq-smoothing FLOAT          EQ smoothing in octave fractions.
                                0 = no smoothing. [default: 0.33]
  --eq-max-gain FLOAT           Max EQ boost/cut in dB. [default: 12.0]
  --loudness-threshold FLOAT    dB below peak LUFS to consider "loud".
                                [default: 2.0]
  --min-segment FLOAT           Minimum crop segment duration in seconds.
                                [default: 5.0]
  --max-segment FLOAT           Maximum crop segment duration in seconds.
                                [default: 30.0]
  --dynamic-range-floor FLOAT   Below this loudness range in dB, skip
                                cropping. [default: 4.0]
  --model TEXT                   audio-separator model filename.
                                [default: model_bs_roformer_ep_317_sdr_12.9755.ckpt]
  --output-sr INT               Output sample rate in Hz. [default: 44100]
  --output-bits INT             Output bit depth (16, 24, 32). [default: 16]
  --keep-ref-stems              Keep reference stem temp files after
                                processing.
  --help                        Show this message and exit.
```

### Validation Rules

- At least one of `--stems-match` or `--mix-match` must be specified. If neither is set, exit with error.
- `--eq-smoothing`, `--eq-max-gain`, `--loudness-threshold`, `--min-segment`, `--max-segment`, `--dynamic-range-floor`, `--model`, and `--keep-ref-stems` are only relevant to `--stems-match`. They are silently ignored if only `--mix-match` is used (no error, just no effect).

### Usage Examples

```bash
# Per-stem EQ match only
songmatch process my_song.wav reference_track.wav --stems-match

# Mix match only (whole-mix, no stems)
songmatch process my_song.wav reference_track.wav --mix-match

# Both: stems-match first, then mix-match on the result
songmatch process my_song.wav reference_track.wav --stems-match --mix-match

# Custom smoothing and gain limit (stems-match)
songmatch process my_song.wav reference_track.wav --stems-match --eq-smoothing 0.5 --eq-max-gain 6.0

# 24-bit output, keep reference stems for inspection
songmatch process my_song.wav reference_track.wav --stems-match --output-bits 24 --keep-ref-stems

# Use HTDemucs instead of BS-RoFormer
songmatch process my_song.wav reference_track.wav --stems-match --model htdemucs_ft

# Full pipeline: stems + mix, no EQ smoothing
songmatch process my_song.wav reference_track.wav --stems-match --mix-match --eq-smoothing 0
```

---

## Loudest Segment Detection

### Algorithm

```
Input:  reference audio (float32 numpy array), sample rate, config
Output: cropped audio array (or full audio if flat track)

1. Compute short-term LUFS envelope:
   - Window size: 3 seconds
   - Hop size: 0.1 seconds (100ms)
   - For each window position, compute integrated LUFS via pyloudnorm
   - Result: 1D array of LUFS values, one per hop

2. Compute dynamic range:
   - peak_lufs = max(envelope)
   - min_lufs = min(envelope), excluding -inf values (silence)
   - loudness_range = peak_lufs - min_lufs

3. Flat track check:
   - If loudness_range < config.dynamic_range_floor:
     - If audio duration <= config.max_segment: return full audio
     - Else: return centered chunk of config.max_segment duration

4. Find loudest segment:
   - threshold = peak_lufs - config.loudness_threshold
   - Create boolean mask: envelope >= threshold
   - Find all contiguous True regions (runs)
   - Filter out regions shorter than config.min_segment
   - If no regions remain after filtering: relax threshold by 1 dB and retry
     (repeat up to 3 times, then fall back to longest region regardless)
   - Select the longest remaining region
   - If region > config.max_segment: trim symmetrically from center

5. Beat-align boundaries:
   - Use librosa.beat.beat_track on the full reference audio
   - Snap start_sec to the nearest preceding beat
   - Snap end_sec to the nearest following beat
   - Ensure final duration still respects min_segment / max_segment

6. Crop and return:
   - Extract the segment from the audio array
   - Return cropped array and sample rate
```

### Edge Cases

- **Very short audio** (< min_segment): skip cropping, use entire reference.
- **All silence or near-silence**: log a warning, use entire reference. pyloudnorm returns -inf for silence — filter these out.
- **Mono vs stereo reference**: pyloudnorm handles both. Ensure consistent channel handling.

---

## Stem Separation

### Implementation

Use `audio-separator` Python API:

```python
from audio_separator.separator import Separator

def separate_stems(audio_path: Path, output_dir: Path, model: str) -> dict[str, Path]:
    separator = Separator(
        output_dir=str(output_dir),
        output_format="WAV",
        output_bitrate=None,   # lossless
        normalization=1.0,     # no normalization
        sample_rate=44100,
    )
    separator.load_model(model_filename=model)
    output_files = separator.separate(str(audio_path))
    
    # Parse output_files list into a stem name -> path dict
    # audio-separator names outputs like: filename_(Vocals).wav, filename_(Drums).wav, etc.
    stems = {}
    for fpath in output_files:
        p = Path(fpath)
        name = p.stem  # contains stem type indicator
        if "vocal" in name.lower():
            stems["vocals"] = p
        elif "drum" in name.lower():
            stems["drums"] = p
        elif "bass" in name.lower():
            stems["bass"] = p
        else:
            stems["other"] = p
    return stems
```

### Notes

- audio-separator auto-downloads model files on first use to a cache dir (default: `/tmp/audio-separator-models/`).
- The BS-RoFormer model produces 4 stems by default when using the standard vocal/instrumental models. For 4-stem (vocals, drums, bass, other), the HTDemucs models or specific BS-RoFormer multi-stem models are needed. Verify which model checkpoint produces the desired stem count.
- If the model produces only 2 stems (vocals + instrumental), fall back to HTDemucs for 4-stem separation. The `htdemucs_ft` model via demucs CLI or audio-separator's demucs backend both work.
- GPU acceleration: audio-separator will use CUDA if available. No special config needed.

### User Stems Storage

User stems are stored in a directory alongside the user audio file:

```
my_song.wav
my_song_stems/
├── vocals.wav
├── drums.wav
├── bass.wav
└── other.wav
```

After EQ matching, the stem files are **overwritten in place** with the EQ-matched versions. The original unmatched stems are not preserved (this is by design — the user has the original full mix if they need to re-separate).

---

## Per-Stem Spectral EQ Matching (Custom)

### Algorithm

```
Input:  user_stem (np.ndarray), ref_stem (np.ndarray), sr (int),
        smoothing_octaves (float), max_gain_db (float)
Output: eq_matched_stem (np.ndarray)

1. Ensure both stems are same sample rate. Resample if needed.

2. Handle stereo:
   - If stereo, average PSDs across channels for a single transfer function.
   - Apply the same transfer function to both channels independently.

3. Compute PSDs:
   - freqs, psd_user = scipy.signal.welch(user_stem_mono, fs=sr, nperseg=8192)
   - freqs, psd_ref  = scipy.signal.welch(ref_stem_mono, fs=sr, nperseg=8192)
   - For stereo: average the two channel PSDs before proceeding.

4. Compute transfer function:
   - H = np.sqrt(psd_ref / psd_user)       # amplitude ratio
   - H_db = 20 * np.log10(H + 1e-10)       # to dB, epsilon to avoid log(0)
   
5. Smooth:
   - If smoothing_octaves > 0:
     - Convert frequency axis to log scale
     - Compute window width in bins for the given octave fraction
     - Apply scipy.ndimage.uniform_filter1d(H_db, size=window_bins)
   - If smoothing_octaves == 0: skip smoothing

6. Clamp:
   - H_db = np.clip(H_db, -max_gain_db, +max_gain_db)
   
7. Boundary conditions:
   - H_db[0] = 0.0    # DC = unity gain
   - H_db[-1] = 0.0   # Nyquist = unity gain

8. Convert back to linear:
   - H_linear = 10 ** (H_db / 20)

9. Design FIR filter:
   - Use scipy.signal.firwin2(numtaps=4095, freq=freqs_normalized, gain=H_linear)
   - freqs_normalized = freqs / (sr / 2)  # normalize to [0, 1] for firwin2

10. Apply filter:
    - For each channel:
      - result = scipy.signal.fftconvolve(channel, fir_filter, mode='same')
    - Stack channels back to original shape.

11. Return eq_matched_stem
```

### Smoothing Detail

The `--eq-smoothing` parameter specifies the smoothing window width in octave fractions. For example:

- `0.33` = 1/3 octave (typical for audio analysis, similar to 1/3-octave graphic EQ)
- `1.0` = 1 octave (very smooth, broad strokes only)
- `0.1` = 1/10 octave (fine detail, may amplify separation artifacts)
- `0` = no smoothing (raw spectral division — dangerous, only for testing)

To convert octave fraction to bin count at a given frequency:

```python
def octave_smooth(H_db, freqs, octave_fraction):
    log_freqs = np.log2(freqs[1:])  # skip DC
    # Uniform spacing in log-freq domain
    # Window width in log2 space = octave_fraction
    bin_spacing = np.median(np.diff(log_freqs))
    window_bins = max(1, int(round(octave_fraction / bin_spacing)))
    H_db_smoothed = H_db.copy()
    H_db_smoothed[1:] = uniform_filter1d(H_db[1:], size=window_bins)
    return H_db_smoothed
```

---

## Stem Recombination

```
Input:  4 EQ-matched stem file paths (vocals, drums, bass, other)
Output: recombined stereo WAV

1. Load all 4 stems as float32 arrays via soundfile.
2. Verify all have the same length and channel count. Pad shorter ones with
   zeros if needed (can happen due to convolution edge effects — should be
   negligible with mode='same' but handle defensively).
3. Sum: combined = vocals + drums + bass + other
4. Peak check:
   - peak = np.max(np.abs(combined))
   - If peak > 1.0: combined = combined * (10 ** (-0.1 / 20)) / peak
     (normalize to -0.1 dBFS)
5. Resample to config.output_sr if stems were at a different sample rate.
6. Convert to target bit depth and write via soundfile.
```

---

## File Naming and Output Conventions

### --stems-match only:

```
Input:   /path/to/my_song.wav

Outputs:
  /path/to/my_song_stemsmatched.wav       # recombined result
  /path/to/my_song_stems/vocals.wav       # EQ-matched vocal stem
  /path/to/my_song_stems/drums.wav        # EQ-matched drum stem
  /path/to/my_song_stems/bass.wav         # EQ-matched bass stem
  /path/to/my_song_stems/other.wav        # EQ-matched other stem

Temp files (deleted after processing unless --keep-ref-stems):
  <tempdir>/ref_cropped.wav               # cropped reference segment
  <tempdir>/ref_stems/vocals.wav          # reference stems
  <tempdir>/ref_stems/drums.wav
  <tempdir>/ref_stems/bass.wav
  <tempdir>/ref_stems/other.wav
```

### --mix-match only:

```
Input:   /path/to/my_song.wav

Outputs:
  /path/to/my_song_mixmatched.wav         # matchering result
```

### --stems-match --mix-match (combined):

```
Input:   /path/to/my_song.wav

Outputs:
  /path/to/my_song_stemsmatched.wav       # stems-match recombined result
  /path/to/my_song_mixmatched.wav         # matchering result (input was _stemsmatched.wav)
  /path/to/my_song_stems/vocals.wav       # EQ-matched stems
  /path/to/my_song_stems/drums.wav
  /path/to/my_song_stems/bass.wav
  /path/to/my_song_stems/other.wav
```

### Postfix convention:

- `_stemsmatched` for stems-match output (recombined per-stem EQ matched)
- `_mixmatched` for mix-match output (matchering result)
- When both modes are used, both postfixed files are created. The `_mixmatched` file is the final "best" output.
- Output files are always placed in the same directory as the user audio input.
- If an output file already exists, overwrite it (no auto-incrementing).

---

## Dependencies

### Runtime

```
soundfile>=0.12        # WAV I/O, all bit depths and sample rates
numpy>=1.24            # array operations, stem summation
scipy>=1.11            # welch PSD, firwin2, fftconvolve, uniform_filter1d
librosa>=0.10          # beat tracking, resampling
pyloudnorm>=0.1        # ITU-R BS.1770 LUFS measurement
audio-separator>=0.25  # stem separation (BS-RoFormer, HTDemucs, MDX-Net)
matchering>=2.0.6      # full-mix EQ matching (--mix-match mode only)
typer>=0.9             # CLI framework
```

### System

- **libsndfile**: required by soundfile. Installs automatically on Windows/macOS; on Linux: `sudo apt install libsndfile1`
- **ffmpeg**: required by audio-separator and matchering for non-WAV input support. `sudo apt install ffmpeg`
- **CUDA** (optional): audio-separator and demucs will use GPU if available. CPU fallback works but is slower.
- **Python 3.10+**: required. Use type hints, dataclasses, and match statements freely.

### Development

```
pytest>=7.0
pytest-cov
ruff                   # linting
```

---

## Configuration Defaults

| Parameter | Default | Unit | Description |
|---|---|---|---|
| `eq_smoothing` | 0.33 | octave fractions | EQ transfer function smoothing width |
| `eq_max_gain` | 12.0 | dB | Max boost/cut clamp for EQ |
| `loudness_threshold` | 2.0 | dB | Below peak LUFS = "loud enough" |
| `min_segment` | 5.0 | seconds | Minimum crop segment duration |
| `max_segment` | 30.0 | seconds | Maximum crop segment duration |
| `dynamic_range_floor` | 4.0 | dB | Below this LRA, skip cropping |
| `model` | `model_bs_roformer_ep_317_sdr_12.9755.ckpt` | — | audio-separator model |
| `output_sr` | 44100 | Hz | Output sample rate |
| `output_bits` | 16 | bits | Output bit depth |

---

## Error Handling

### Fail-fast conditions (raise and exit):

- Neither `--stems-match` nor `--mix-match` specified.
- Input file does not exist or is not a supported audio format.
- audio-separator model fails to download or load.
- Stem separation produces unexpected number of stems (not 4).
- matchering raises an exception during processing.

### Graceful degradation:

- Loudest segment detection cannot find a suitable segment → use full reference, log warning.
- PSD computation produces zeros or NaN → set those bins to unity gain (0 dB), log warning.
- FIR filter design fails for some reason → skip EQ for that stem, copy unmodified, log warning.
- Output file already exists → overwrite without prompt.

### Logging:

- Use Python `logging` module throughout.
- Default level: INFO (shows progress: "Analyzing loudness...", "Separating stems...", "Matching EQ for vocals...", etc.)
- `--verbose` / `-v` flag (add to CLI) sets DEBUG level for detailed output including LUFS values, segment boundaries, filter parameters.

---

## Testing Strategy

### Unit tests:

- **`test_loudness.py`**: Test LUFS envelope computation with synthetic signals (sine wave at known dB, silence, mixed). Test segment detection with known envelopes (single peak, plateau, flat). Test beat snapping with known beat grids.
- **`test_eq_match.py`**: Test PSD computation with white noise (should be flat). Test transfer function with known spectral differences (e.g., +6dB shelf). Test smoothing with impulse-like transfer functions. Test gain clamping.
- **`test_audio_io.py`**: Test load/save roundtrip at various bit depths. Test resampling.
- **`test_utils.py`**: Test path naming conventions.

### Integration tests:

- **`test_pipeline.py`**: End-to-end test with short (5-second) synthetic audio files. Three cases:
  - `--stems-match` only: verify `_stemsmatched.wav` and stem files are created.
  - `--mix-match` only: verify `_mixmatched.wav` is created, no stems directory.
  - Both flags: verify both output files exist, and that `_mixmatched.wav` was produced from `_stemsmatched.wav` (check that it differs from a standalone `--mix-match` run).
  - Neither flag: verify error exit.
  - Verify output format (sample rate, bit depth, channel count) in all cases.
- **`test_mix_match.py`**: End-to-end test of matchering path with short audio files. Test that `target_audio` parameter correctly accepts both original and stems-match output.

### Test fixtures:

- Generate synthetic test audio programmatically (sine waves, white noise, pink noise) rather than shipping audio files. Use `numpy` and `soundfile` to create them in a pytest fixture.

---

## Future Considerations (Not in Scope for v1)

- **6-stem mode**: Switch to BS-RoFormer-SW model for vocals, drums, bass, guitar, piano, other. Requires adjusting the stem mapping logic and EQ matching to handle 6 pairs.
- **Per-stem smoothing overrides**: Allow different `--eq-smoothing` values per stem (e.g., less smoothing on drums, more on vocals).
- **Dry/wet mix**: Blend between original and EQ-matched stems.
- **Loudness normalization**: Optional LUFS-targeted normalization of the final output (e.g., -14 LUFS for streaming).
- **Batch processing**: Process multiple user files against the same reference in one run.
- **Progress reporting**: Callback-based progress for potential GUI integration.
- **Parallel stem processing**: EQ matching of the 4 stems is embarrassingly parallel — could use `concurrent.futures` for speedup.
