# ⚓ 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>" --tracks MAREA ANGIE --jobs 3 --out /tmp/cut
python3 -m engine.kitcheck /tmp/cut/fred_kits.json      # audit what landed
```

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.

**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.

## 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.
