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

---

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