# ⚓ The Foundry — ParVagues sample foundry

**URL → audio → stems → (soon) loops.** Turns a track URL into separated stems
ready for sampling. One engine, two faces: a CLI (`foundry.py`) and a web GUI
(`foundry.py serve`) that both call the same `engine/` steps.

Replaces the manual `yt-dlp → activate demucs venv → demucs --shifts 4 → open
4 stems in Audacity → find loops` dance. engine1 ships the **download + stem**
half today; the loop-finder (the Audacity-replacement) is phase 2.

## Quickstart

```bash
cd tools/foundry
python3 foundry.py backends                          # what separation engines are live
python3 foundry.py catch 'https://youtu.be/…'        # full pipeline: fetch + separate
python3 foundry.py serve                             # GUI at http://127.0.0.1:8765/
```

Workspace defaults to `$FOUNDRY_HOME` or `~/Downloads/foundry`. Each catch is a
self-describing folder — the filesystem *is* the database:

```
~/Downloads/foundry/<slug>/
  source.webm            # downloaded bestaudio
  source.info.json       # yt-dlp metadata sidecar (provenance)
  stems/{drums,bass,other,vocals}.wav
  catch.json             # the Catch model (title, url, backend/model, stems…)
```

## CLI

```bash
foundry.py fetch <url>        # download bestaudio → a Catch (no separation)
foundry.py sep <slug|url>     # separate an existing catch (or fetch+sep a url)
foundry.py catch <url>        # fetch + sep (the whole pipeline)
foundry.py list               # ▣ separated / ▢ raw, per catch
foundry.py backends           # ✓/✗ + hint per backend
foundry.py link <kit>         # symlink Samples/<kit> → Dirt-Samples (s "<kit>")
foundry.py serve [--port]     # launch the GUI

# separation knobs (sep / catch):
  --backend demucs            # which engine (demucs today, roformer next)
  --model htdemucs            # backend model
  --shifts 4                  # demucs equivariant shifts (quality↔time)
  --jobs N  --device cuda|cpu
```

Runs under **system python3** (pydantic). Heavy work shells out to dedicated
venvs (demucs at `~/.virtualenvs/demucs/bin/demucs`; override `$FOUNDRY_DEMUCS`).

## Tests

```bash
cd tools/foundry && python3 -m pytest tests/ -q   # pure engine: slug, parse, argv, registry
```

---

## Use as an API (from another project)

The Foundry is the shared **demucs engine** for the wider toolbox — e.g. the
emotional-arc / section-analysis tool consumes it to get stems. Three ways to
integrate, cheapest first:

### 1. Python import (in-process — best for a sibling Python tool)

Add the foundry dir to `sys.path`, then call the same steps the CLI does. Runs
under system python3 (needs `pydantic`); the demucs subprocess uses its own venv.

```python
import sys; sys.path.insert(0, "/home/pln/Work/Sound/Tidal/tools/foundry")
from pathlib import Path
from engine import fetch, separate
from engine.model import Catch

ws = Path("~/Downloads/foundry").expanduser()      # or set $FOUNDRY_HOME

catch = fetch.fetch("https://youtu.be/…", ws)      # download → Catch
catch = separate.separate(catch, ws, backend="demucs", shifts=4,
                          progress=lambda p: print(f"{p}%"))

# stems are now on disk; resolve absolute paths from the Catch:
stems = {s.name: catch.dir(ws) / s.path for s in catch.stems}
bass_wav = stems["bass"]                            # → feed your analysis

# already have a catch? skip fetching:
catch = Catch.load(ws / "some_slug")                # reads catch.json
```

Idempotency: `fetch` re-probes but re-downloads only if missing; check
`catch.separated` before re-separating. `separate.REGISTRY` lists backends;
`separate.get_backend("demucs").available()` → `(bool, hint)`.

### 2. CLI subprocess (language-agnostic)

```python
import subprocess, json
from pathlib import Path
F = "/home/pln/Work/Sound/Tidal/tools/foundry/foundry.py"
subprocess.run(["python3", F, "catch", url], check=True)        # fetch + separate
# then read the catch.json the run wrote:
ws = Path("~/Downloads/foundry").expanduser()
slug = sorted(ws.iterdir())[-1].name                            # or derive from title
catch = json.loads((ws / slug / "catch.json").read_text())
```

### 3. HTTP API (for a running server / remote / a non-Python caller)

`python3 foundry.py serve` then poll jobs:

```
POST /api/fetch      {url}                  → {id}          # start download job
POST /api/separate   {slug,backend,shifts}  → {id}          # start separation job
GET  /api/job/<id>                          → {state,progress,msg,slug}
GET  /api/catches                           → [Catch, …]
GET  /api/backends                          → [{name,available,hint}]
GET  /media/<slug>/<path>                   → audio (HTTP Range / 206 — seekable)
```

`state` goes `running → done|error`; on `done`, read `/api/catches` or
`/media/<slug>/stems/bass.wav`.

### The contract you can rely on

- Stems always land at `<workspace>/<slug>/stems/{drums,bass,other,vocals}.wav`
  (normalized regardless of backend), 44.1 kHz WAV.
- `catch.json` is the source of truth (title, `source_url`, `backend`, `model`,
  `stems[]`, `duration`) — parse it, don't scrape filenames.
- Override paths via env: `$FOUNDRY_HOME` (workspace), `$FOUNDRY_DEMUCS` (binary),
  `$FOUNDRY_SAMPLES` / `$FOUNDRY_DIRT_SAMPLES` (publish targets).
- **Don't infer a stem's musical role from its name** (`other`≠melody,
  `bass`≠sub) — analyze it. (Project rule; see CLAUDE.md.)

> Beta-testers: file what's awkward. The Python import path is the one to harden
> first — tell me if you'd rather have a `pip install -e`-able package.

---

## Producer stem packs (`engine.stempack`)

Not every source needs separating. A producer stem pack — Fred again..'s dropbox is the
worked example — arrives already split, by the human who mixed it, into stems named
`MAREA MIX10 123BPM KIT STEM.wav`. That is better material than demucs can produce and
completely outside what `find_takes` accepts, so `engine/stempack.py` is the batch path
for it (TODO #20).

```bash
cd tools/foundry
python3 -m engine.stempack "<pack dir>" --jobs 9 --no-link --out /tmp/cut   # cut
python3 kitgate.py /tmp/cut/fred_kits.json                                 # gate
python3 kitgate.py /tmp/cut/fred_kits.json --link                          # …then link
```

Cut with `--no-link` and let `kitgate.py` decide. It re-runs `grade` over the written
files (same verdict by a different code path — a disagreement means one of them is
wrong), runs the `kitcheck` audit, and checks for **bank shadowing before anything is
linked**: `loadSoundFiles` defaults to `appendToExisting = false`, so a folder whose
basename matches an existing bank frees that bank's buffers and takes the name, with no
error and no symptom except an old track playing the wrong sound.

`restage.py` applies the same kit-wide gain staging to kits already on disk — including
hand-cut ones, which carry whatever level their source had:

```bash
python3 restage.py --all-under ~/Work/Sound/Samples --dry-run
```

One kit per **track × role family** (`fred_marea_drums`, `fred_marea_vox`), files named
`NN_<producer role>_<bars>b.wav`, linked into Dirt-Samples so `s "fred_marea_drums"`
resolves. `fred_kits.tidal` is a paste-ready cheat sheet with the `n` index of every
sample, because a kit you cannot address is a kit you will not use.

### Four things this had to get right

**Resample before analysing.** Packs like this ship at 96 kHz, and librosa's hop/window
defaults are sample-rate-relative — at 96 k every analysis frame spans half the musical
time and beat tracking wanders. MAREA declares its tempo in the filename, so it is free
ground truth: at 96 kHz the finder returned 123/123/124/**119**/**128.1**/**128.5** bpm
with a 0.661 top score; at 44.1 kHz it returned 123.0–123.1 on every candidate with
0.859, in a third of the time. Rate is a correctness input, not a performance knob.

**One tempo per track.** `analyze_stem` reports each candidate's bpm as the mean local
BPM inside its own window — right for detecting drift, wrong for deriving a bar length,
because then every loop gets its own tempo and two loops from one track do not layer.
Bar length comes from the *mode* of the local-BPM histogram, with a confidence (the
share of beats within 5 % of it). Below 35 % there is no pulse worth cutting bars
against and the track ships chops only — ME, which has no drum stem at all, is the case
that motivated it.

**Modes rank in separate pools.** `grade` scores anything under 0.75 s as a one-shot on
`0.5·level + 0.3·dc + 0.2·zc` — no seam, no bar term. Export already removes DC and snaps
zero crossings, so chops land near 1.0 while loops pay 0.35 on seam and 0.25 on bar
consistency. Head to head, chops take every slot: measured on ME, chops 0.993–0.999,
loops 0.841–0.881, and the vocal kit came out as three stabs with no loops in it.

**Stage the level, because `amp = gain^4`.** Producer stems come off a loud master —
MAREA's ALL DRUMS and KIT both peak at +0.2 dBFS — so a loop cut from them arrives at full
scale, which `grade` calls clipping and SuperDirt cannot use: `# gain 1.2` is +7.6 dB and
the orbit clips the moment you touch it. Kits land with their hottest file at −6 dBFS, as
**one gain for the whole kit** (per-file normalisation would make a sparse hat loop as loud
as a full drum bus and throw away the balance two loops from one kit otherwise have for
free) and **never boosting** (a quiet stem is quiet on purpose).

**The label is evidence, not truth — and neither is the measurement.** `roles.py` maps
the pack's ~40 role tokens to a family and then measures the stem. Presence and bass are
decided by measurement (a stem under −40 dBFS peak has nothing in it; "all energy under
150 Hz, nothing above 2 kHz" is unambiguous). Everything else keeps the producer's label,
because onset density cannot separate a rhythmic rhodes from a drum bus — MAREA's KEYS2
reads 5.1 onsets/s and its sparse CLAP reads 1.0, so measurement alone would file both
in the wrong kit, silently. Disagreements are reported rather than resolved.

### What `kitcheck` catches that `grade` cannot

`grade` answers *is this mechanically a clean loop*. All of these pass it:

- **dead bars** — a 4-bar loop whose last two bars are a fade has a silent-to-silent
  seam and an exact length, so it grades S. Per-bar RMS finds it.
- **not actually a loop** — the finder cuts on a beat grid, so its windows are always
  bar-*aligned*; nothing checked they were a bar-length *unit*. Onset-envelope
  autocorrelation at the bar lag does.
- **near-duplicates** — ALL DRUMS, KIT, MAIN DRUM LOOP and DRUM BREAKS are four views of
  one groove, and the finder picks the same bar out of all four. Eight slots holding
  three sounds is a worse kit than three. Dedup runs at export; `kitcheck` re-checks with
  the same fingerprint and threshold so the two cannot disagree.

Two things about the periodicity test are worth knowing before reading its numbers.
Autocorrelation at lag *k* has only *N−k* overlapping frames, so normalising by `ac[0]`
scores identical material lower the shorter the loop — measured 0.418 / 0.623 / 0.754 at
2 / 4 / 8 bars on a perfect click track, which is length, not rhythm. And the floor is
**per family**, because the medians split hard by role (drums 0.754, fx 0.549, tonal 0.214,
bass 0.173, vox 0.087): a drum loop that does not repeat at the bar is broken, while a sung
phrase that does not repeat within itself is a sung phrase. One threshold for both condemned
12 of 20 vocal loops for being vocal.

## Engine roadmap — engine1 → engine2

The separation backend is a **registry** (`engine/separate.py`); the CLI/GUI ask
for "a backend" and never hardcode demucs. That's the whole point.

### engine1 — Demucs (htdemucs) · LIVE
Meta's Demucs v4, the model PLN already runs by hand. Fits our **6 GB RTX 2060
Max-Q** comfortably. Reliable baseline for "grab a usable stem". Knobs: `--shifts`
(more = cleaner separation via test-time augmentation, linearly slower; 1 for
scratch, 4–10 for keeps), `--model` (`htdemucs` default, `htdemucs_ft` = the
fine-tuned 4-model bag, ~4× slower, a touch cleaner).

### engine2 — BS-/Mel-Band Roformer (SOTA) · STUB
Frequency-domain transformers (ByteDance) now top the MVSEP leaderboard —
**~+3 SDR on vocals** (≈11.9 vs htdemucs 8.8), ~9.76 SDR on MUSDB18HQ, better
transients + low-end (exactly what matters for carving clean bass/drum loops).
Wire via [`python-audio-separator`](https://github.com/nomadkaraoke/python-audio-separator)
(wraps the UVR Roformer ckpts) in its own venv; register a working
`RoformerBackend`.

**Hyperparams to expose** (audio-separator / MDXC):
| knob | default | trade-off |
|------|---------|-----------|
| `segment_size` | 256–512 | larger = more context, better quality, **more VRAM** |
| `overlap` | 8 (range 2–50) | higher = fewer chunk-seam artifacts, slower |
| `batch_size` | 1 | higher = faster, more RAM |
| `chunk_size` (infer) | 352800 (8 s) | the value models trained on; rarely change |
| model ckpt | `bs_roformer_…sdr_12.97` | pick per stem target (vocal vs 4-stem) |

**The 6 GB constraint is the open question:** Roformer is VRAM-hungry. Before
making it a default, run a **VRAM spike** — find the largest `segment_size` that
fits 6 GB (fall back to `--device cpu` or smaller segments / lower batch).
Sources: [MVSEP leaderboard](https://mvsep.com/en/algorithms),
[Mel-Band RoFormer paper](https://arxiv.org/html/2310.01809v1),
[audio-separator](https://pypi.org/project/audio-separator/).

---

## Phase 2 — the loop finder (Audacity-replacement)

Charter for a dedicated deep-research pass (Sonnet 4.6 agents): study the
ParVagues `_samples` folder + sampling fundamentals, and design an **easy**
loop-detection engine that automates the mechanical drudgery while **leaving the
creative loop-choice to PLN's ear**. Lands as `engine/loops.py` + a GUI panel.

Automate (machine): tempo/beat/downbeat grid (librosa), bar-aligned candidate
regions, **zero-crossing snap** for click-free loop points, energy/novelty to
rank candidates, one-shot vs loop classification. Leave to the human: *which*
loop is musical, naming, the creative cut.

## UX roadmap (asked-for surface: save / settings / analyse / review / folders)

- **Catch review** — the card list is the review surface; per-stem `<audio>`
  (Range-seekable). Phase 2 adds waveforms (wavesurfer.js) + loop regions.
- **Save / export** — today: `⬇ wav` per stem. Phase 2: "send loop → sample
  folder" with a Tidal-friendly name into **`~/Work/Sound/Samples/<kit>/`** (the
  real custom SuperDirt sample root — where crimewave/diams_dj/humpty already
  live), then `foundry.py link <kit>` symlinks it into Dirt-Samples so it loads
  as `s "<kit>"`. The link step is live today (`engine/publish.py`).
- **Settings** — engine + shifts live in the GUI opts bar; later: persisted
  defaults (engine, output sample-rate, target folder) in a `foundry.config.json`.
- **Analyse** — reuse `tools/analyze_samples.py` / tide-table `audio_lens.py` for
  EDA (centroid, fundamental, band energy) on each stem before sampling — the
  required mastering-EDA step, surfaced inline so role is *validated, not guessed*.
- **Folders** — workspace per-catch dirs (above); a "catches" library view with
  search/filter as the corpus grows.
