# Phase 2 — Loop Finder: findings & design (strand D synthesis)

> Synthesizes strands **A** (empirical, `A_loop_profile.md`), **B** (fundamentals,
> `B_fundamentals.md`), **C** (prior art, `C_prior_art.md`). 2026-06-22.
> This is the design `engine/loops.py` (task #5) and the GUI panel (task #6)
> implement against. Validation plan = task #7.

---

## 0. The model (corrected by PLN, 2026-06-23)

The real flow: **demucs a whole track → loop-find 1..x loops *per stem*,
independently.** Cuts are *often* time-aligned across stems (the same `[x,y]`
window grabbed for bass + synth + drums) — but this is a **tendency, not a rule**:
the count varies per stem and some stems are skipped entirely (humpty exported no
drums). Strand A's "one window → 4 stems" was too rigid.

So the finder works **per stem**: generate 1..x ranked loop candidates for each
stem. Then **cross-stem time-alignment is a grouping signal, not a constraint** —
candidates from different stems that share a time window are clustered into a
loose **Take** (a time region) the human can audition together, but each stem
still contributes its own count (0..x), name, and keep/drop decision.

PLN picks 1–4 "hook moments" per source, cuts **2- or 4-bar regions** (strand A's
duration peaks hold), and names by role+character (`vocals_dark_lips`,
`keys_brass3_MINOR`), never by stem name. Level is kept as-cut (no normalize).

**Section labels ground the loops (PLN, 2026-06-23).** Song-structure analysis
(intro / verse / chorus / bridge / drop / outro) gives each loop a *grounding
tag* — it labels, identifies, and locates the cut ("this is the chorus synth",
"the drop bass"). The §1 beat-sync SSM + Foote novelty already segments the
track; labeling those segments by structural role is a small step on top, and the
**sibling emotional-arc / section tool (#89) already does section + arousal
analysis on stems** — reuse its output rather than rebuild. Feeds: the finder
(rank/locate by section), naming (task #13: `chorus_synth`, `drop_bass1`), and the
human's mental map of a source. The arc tool consumes the Foundry engine for
stems → the Foundry's finder consumes the arc tool's sections. Clean loop.

```
ParVagues loop, empirically (strand A):
  duration   2 or 4 bars @ 100–130 bpm  →  3.7–8.0 s covers 85% of loops
             (peaks: 4–5 s = 2-bar, 7–8 s = 4-bar; one-shots <2 s are 11%)
  level      RMS −12…−28 dBFS — moderate; DO NOT normalize (SuperDirt gain shapes it)
  spectral   skews low-mid (500–2000 Hz centroid), harmonic > noisy
  yield      keeps 27–58% of source as 1–4 moments × 4–12 elements = 9–24 loops/kit
```

---

## 1. The pipeline (strand C — zero new deps, ~5.4 s per 120 s stem)

All `librosa` + `scipy` + `numpy`, already in the tide-table venv. **madmom is
deferred** (NumPy-2.x incompatible — build-chain risk); so are BeatNet, laplacian
segmentation, and "Beat This!".

1. **Beat grid — drift-tolerant.** `librosa.onset.onset_strength` →
   `librosa.beat.plp` (predominant local pulse) → `scipy.signal.find_peaks`.
   PLP gives per-frame local tempo, so it tracks `setcps` drift. **Never use
   `beat_track` as primary** (single-BPM DP; A confirms it's unreliable on this
   material).
2. **Beat-synchronous SSM.** MFCC(13) ⊕ chroma_cqt(12) → `librosa.util.sync` onto
   the beat grid → `librosa.segment.recurrence_matrix(mode='affinity')`. ~224×224
   for a 120 s stem (~0.45 s). The beat axis absorbs tempo drift for free.
3. **Foote checkerboard novelty** on the *beat-sync* SSM, two passes: k=4 (1-bar)
   and k=16 (4-bar). 13 ms on 224×224. **Never run frame-level** (5168×5168 = 27 s).
4. **Candidate windows** at {1, 2, 4, 8} bars between novelty peaks (A: try
   [2, 4, 8] first — the empirical sweet spot).
5. **Score** each window (§3) and **dedup**.
6. **Zero-crossing snap** (<1 ms) at export, after the human picks.

---

## 2. The mechanical rulebook (strand B — what the machine must honour)

| # | Rule | Param |
|---|------|-------|
| B1 | Snap endpoints to **same-direction** zero crossings | ±10 ms / ±441 samp |
| B2 | Remove DC offset first (≤10 Hz HP / mean-sub); flag \|mean\|>1e-4 | before ZC search |
| B3 | **Equal-power** crossfade at seam (`√t`/`√(1-t)`), not linear | 20 ms sustained, **3 ms drums** |
| B4 | Bar length `samples = bars*4*60/bpm*sr`; quantize ends to beats | — |
| B5 | **No global BPM** — PLP per-region; store bpm_mean/min/max; flag >±5 BPM as tempo-unstable | drift hazard |
| B6 | Bass → **mono-sum** (`mean(axis=0)`); check inter-channel corr first | demucs bass is stereo |
| B7 | Drums: loop-in ≥5 ms **before** first transient; short fade only | protect attack |
| B8 | Output **24-bit PCM WAV @ 44100 Hz**; resample *before* ZC search | SuperDirt/SC rate |
| B9 | One-shot if < 0.75 s; min loop = 1 bar at local BPM | classify |
| B10 | **Do NOT normalize** (A overrides B's −1 dBFS default: PLN keeps stems as-cut; SuperDirt gain handles level). Offer optional peak-norm as a toggle. | — |

**The central conflict (B + A):** bar-align (snap to downbeat) vs zero-crossing
(snap to nearest ZC). Resolution: **always honor the musical downbeat**, let the
equal-power crossfade absorb the residual seam, and **never move >25 ms from the
detected beat**. A confirms this is safe: `loopAt` in the `.tidal` tracks already
tolerates 1–5% timing error (e.g. bombe_dj stretches a 7.83 s loop 1.1% — transparent),
so **bar-boundary alignment, not sample-perfect timing, is the real constraint**.

---

## 3. Loopability score (composite, per window)

```
score =  w_struct * ssm_diagonal_similarity      # does this window repeat? (structural)
       + w_novel  * boundary_novelty              # Foote peak strength at the edges
       + w_seam   * (1 - seam_discontinuity)      # waveform cross-corr at the loop seam
       + w_zc     * zc_proximity                  # how close a clean ZC is to the beat
       - penalty  * tempo_instability             # demote windows near setcps jumps
```
Defaults `w_struct=0.4, w_novel=0.2, w_seam=0.3, w_zc=0.1` — tune against §5.
The seam term is computed on the **summed mix** (a click anywhere is audible).

**Tempo self-check (A's most actionable finding):** for a candidate at bar-length
L and tempo T, the *implied* duration must match a clean bar count within ±2%. PLN's
own loops cluster to bit-identical durations per source — so if PLP's T and the
{2,4,8}-bar grid don't produce a self-consistent L, the tempo estimate is suspect →
flag, widen ZC window, demote.

---

## 4. Data model & engine API (`engine/loops.py`)

```python
class StemSlice(BaseModel):       # one stem, cut to the window
    name: str                     # drums|bass|other|vocals
    path: str                     # written wav (relative to catch dir, until exported)
    rms_dbfs: float
    onset_rate: float
    centroid_hz: float
    is_one_shot: bool

class Take(BaseModel):            # a bar-window = the unit PLN selects
    start_sample: int; end_sample: int
    start_beat: int;   end_beat: int
    bars: int                     # 1|2|4|8
    bpm: float; bpm_min: float; bpm_max: float
    tempo_unstable: bool          # bpm range > ±5
    score: float                  # composite (§3)
    seam_discontinuity: float; novelty: float; structural: float
    crossfade_ms: float; zc_residual_ms: float
    stems: list[StemSlice]        # all 4 (or fewer) from THIS window

# pure-ish; reuses tide-table sample_features where it can
def find_takes(catch, workspace, *, bars=(2,4,8), top_n=8,
               progress=None) -> list[Take]: ...

# export: honor B-rules, write to Samples/<kit>/, then publish.link_kit(kit)
def export_take(take, kit, *, keep=("drums","bass","other","vocals"),
                names=None, peak_norm=False) -> list[Path]: ...
```

`export_take` is the only side-effecting step; it applies B1–B10, writes to
`~/Work/Sound/Samples/<kit>/`, and calls the already-built
`engine.publish.link_kit` to symlink into Dirt-Samples (`s "<kit>"`).

---

## 5. The rubric is corpus-wide — grade everything, not just 3 pairs

> Reframe (PLN, 2026-06-23): the 3 GT pairs are too thin, and the whole point is
> bigger — **every existing kit is a hand-cut loop that the rubric can grade.**
> The §3 score is not just the finder's internal ranker; it is a **loop grade**
> applicable to any wav. So build it as a shared, standalone instrument.

### 5·0. The Correlation Engine — ground truth at corpus scale (PLN, 2026-06-23)
Don't hand-pair 3 kits — **correlate every `Samples/**/*.wav` against every
`separated/htdemucs/**/*.wav`** to auto-recover each loop's provenance: which
source track, which stem, the start offset, and the length. Because a hand-cut
loop *is literally a slice of the stem*, matching is high-confidence:
- align by normalized cross-correlation on a cheap embedding (beat-sync
  chroma+MFCC, or a downsampled mel fingerprint) to find the offset; confirm with
  sample-domain xcorr on the matched span.
- only the ~recent demucs-derived kits will match (separated/ holds 27 tracks vs
  135 kits — older kits predate this workflow); **quantify coverage**, don't
  pretend full.

What this buys (all measured, not assumed):
- **provenance map** `sample → (track, stem, offset, len)` — dozens of GT pairs, free.
- **cross-stem alignment stats** — the actual answer to "how often are loops
  time-aligned across stems?" (the §0 tendency, quantified).
- **the cut critique** — compare each human cut to its source: was the boundary
  bar-aligned? on a clean ZC? this is exactly what the finder must reproduce.
- feeds both the tierlist (5b) and the finder recall test (5c).

### 5a. The Loop Grader (the katana — `feedback_build_katana_first`)
A pure function `grade(wav) -> LoopGrade` applying the A-profile + B-rulebook to
**any** sample: seam discontinuity (loop the file head-to-tail, measure the click),
ZC cleanliness at ends, DC offset, bar-length self-consistency (does duration map
to a clean {1,2,4,8}-bar count at a detected tempo within ±2%?), level sanity,
bass mono-compatibility, one-shot-vs-loop class. Output a 0–1 grade + per-rule
sub-scores + flags. **This is the same code the finder uses to score candidate
windows** (DRY): the finder grades hypothetical windows, the tierlist grades
existing files.

### 5b. Foundry Hall of Fame / tierlist
Run the grader over all 135 kits / 1641 wavs → rank into tiers (S/A/B/C/D). Two
payoffs:
- **QA on the library** — surface the few hand-cut loops with audible seams,
  DC offset, or off-grid durations (fixable); celebrate the exemplars.
- **Validate the rubric itself** — usage is implicit ground truth: the **9
  core-palette kits** (used 21+ times in `.tidal`) and the 106 used kits should
  tier *higher* than the 29 unused/staged kits. If they don't, the rubric is
  wrong, not the library. Correlate grade vs `.tidal` usage count.

Render as a tierlist viz in the Ship's Bridge language (later `/impeccable` pass);
the grader emits JSON the viz consumes (parsers-over-copy, `feedback_parsers_over_copy`).
PII: aggregate/representative, not full filename dumps (`feedback_pii_sensitivity`).

### 5c. Finder recall (still useful, now secondary)
The 3 GT pairs remain a sharp **finder** test: does `find_takes(top_n=N)` surface
the windows PLN kept? Match within ±1 bar; tempo within ±2% (crimewave
121.8/127.2/128.5, diams_dj 122.6, humpty ~102). Wire as pytest IT with GT
durations as fixtures.

**v1 targets:** grade↔usage correlation positive & significant across the corpus;
core-palette kits ≥ B-tier; finder recall@8 ≥ 0.7 on the 3 GT kits; tempo ±2% on ≥2/3.

---

## 6. GUI (task #6) — fast chooser, never autocrop

Per-catch **Loops** panel: wavesurfer.js waveform of the summed mix with novelty
peaks + ranked candidate windows shaded. Click a window → audition all 4 stems
looping in sync. Human prunes stems, names by role, hits **Forge** → `export_take`
→ auto-link. Suggestions are *never* auto-applied; a wrong rank costs one click to
ignore (the house rule).

## 7. Biggest risk & mitigation

PLP needs ~8 s of context to converge after a `setcps` jump, so beat frames near
tempo transitions can be off 1–2 beats. Mitigate: widen the ZC-snap window for
candidates landing near SSM novelty peaks, let the composite score demote off-seam
windows, and lean on the human final-pick as the ultimate safety net.

## 8. Build order (PLN: all of it, 80/20 first, clean-tested)

**Decided 2026-06-23.** Each stage feeds the next; rubric validated before the
finder leans on it; the showcase lands early.

1. **Loop grader** (`engine/grade.py`) — pure `grade(wav)->LoopGrade` over §2
   rules + §3 score: seam, ZC, DC, bar-length self-consistency, level, bass-mono,
   one-shot class. The katana — unit-tested, reused by finder + tierlist. *80/20:
   ship the unambiguous rules first (seam/ZC/DC/duration), refine spectral after.*
2. **Hall of Fame tierlist** (`grade_corpus.py` → JSON → impeccable viz) — grade
   all 135 kits, rank S/A/B/C/D, flag flawed cuts, correlate grade vs `.tidal`
   usage (rubric validation: core-palette kits must tier high). Full Ship's Bridge
   showcase, audition-on-click.
3. **Correlation engine** (`engine/correlate.py`) — §5·0 provenance recovery;
   enriches the tierlist with the cut-critique + yields GT at scale for #7.
4. **Loop finder** (`engine/loops.py`) — §1 pipeline, **per-stem** candidates +
   loose cross-stem grouping (§0), scored by the grader's loopability rubric (DRY).
   Then `export_take` (B1–B10) + `publish.link_kit`.
5. **GUI loop panel** (task #6) → **finder recall IT** (task #7, uses #3's GT).
6. **engine2 Roformer** (tasks #8→#9) — independent; cleaner stems anytime.

Commit per stage (`feedback_commit_often`); branch, not master.
