Commit 14e69fe5 by PLN (Algolia)

feat(foundry): merge-of-stems multimodel — Roformer vocals + demucs rest (#9)

PLN's "merge of stems": the best vocal isolation cascaded into the best 4-way split.
There is no 4-stem Roformer, so MultiModelBackend orchestrates a cascade —
  1. Roformer (audio-separator, py3.12 venv) isolates vocals + instrumental;
  2. demucs runs on the INSTRUMENTAL (vocals already gone → cleaner) for drums/bass/other;
  3. merge = Roformer vocals ⊕ demucs drums/bass/other in the standard 4-stem contract.

Refactored separate.py for composite backends: a backend declares what it `produces`
and (for composites) a `run_composite` hook; the subprocess runner is factored into
_run_cmd so the cascade can drive several sub-separations. RoformerBackend is now wired
(was a stub): audio-separator CLI, 2-stem (vocals/instrumental), normalize() globs the
"(Vocals)"/"(Instrumental)" output names. The default normalizer maps by produces[].

Verified end to end on the Loituma source: merge → vocals (Roformer) + drums/bass/other
(demucs-on-instrumental), 4 clean PCM_16/44.1k stems. All three backends report
available on this box. GUI renders whatever stems a backend emits (demucs/merge 4,
roformer 2 incl. instrumental), canonical roles first. 53 tests (added: registry has the
three engines, roformer produces 2 + builds its cmd, merge is composite/4-stem).

Keeps the 2-stem Roformer door open as its own backend (vocal isolation), and the merge
as the flagship. Foundry's engine registry now showcases the pluggable design PLN wants
to grow into a product/API/service.
parent 7c3154a4
"""separate — split a source into stems via a pluggable backend. """separate — split a source into stems via a pluggable backend.
The whole point of the registry is the engine1 → engine2 path: today demucs The registry drives the engine1 → engine2 path: demucs (htdemucs, what PLN ran by
(htdemucs, the model PLN already runs by hand), tomorrow a Roformer SOTA model hand), Roformer (BS-/Mel-Band via `audio-separator`), and the **merge** backend —
(BS-/Mel-Band Roformer via `audio-separator`) behind the *same* interface. The PLN's "merge-of-stems multimodel": SOTA Roformer vocal isolation cascaded into
CLI/GUI ask for "a backend"; they never hardcode demucs. demucs on the instrumental for drums/bass/other, best-of-both in the 4-stem contract.
Backends shell out to their OWN venv binary, so this module — and everything Backends shell out to their OWN venv binary, so this module runs cleanly under
that imports it — runs cleanly under system python3. system python3. A backend declares what it `produces`; composite backends override
`run_composite` to orchestrate several sub-backends.
""" """
from __future__ import annotations from __future__ import annotations
import glob
import os import os
import re import re
import shutil import shutil
...@@ -19,16 +21,18 @@ from typing import Callable, Optional ...@@ -19,16 +21,18 @@ from typing import Callable, Optional
from .model import STEMS, Catch, Stem from .model import STEMS, Catch, Stem
# demucs prints a tqdm bar to stderr; grab the integer percent best-effort. # demucs / audio-separator print a tqdm-ish bar to stderr; grab the integer percent.
_PCT = re.compile(r"(\d+)%\|") _PCT = re.compile(r"(\d+)%\|")
class Backend: class Backend:
"""A separation backend. Subclasses fill in the three hooks.""" """A separation backend. Subclasses fill in the hooks."""
name = "base" name = "base"
default_model = "" default_model = ""
models: list[str] = [] # selectable model variants (for the GUI/CLI) models: list[str] = [] # selectable model variants (for the GUI/CLI)
produces: tuple = STEMS # the stem names this backend emits
composite = False # True ⇒ orchestrates sub-backends via run_composite
def available(self) -> tuple[bool, str]: def available(self) -> tuple[bool, str]:
"""(is_runnable, human hint). Override.""" """(is_runnable, human hint). Override."""
...@@ -38,7 +42,22 @@ class Backend: ...@@ -38,7 +42,22 @@ class Backend:
raise NotImplementedError raise NotImplementedError
def stem_dir(self, source: Path, outdir: Path, model: str) -> Path: def stem_dir(self, source: Path, outdir: Path, model: str) -> Path:
"""Where this backend writes its {stem}.wav files.""" """Where this backend writes its stem files."""
raise NotImplementedError
def normalize(self, src_stems: Path, stems_dir: Path, catch_dir: Path) -> list[Stem]:
"""Move the backend's raw outputs → stems_dir/{name}.wav. Default = by name."""
found: list[Stem] = []
for name in self.produces:
wav = src_stems / f"{name}.wav"
if wav.exists():
dest = stems_dir / f"{name}.wav"
shutil.move(str(wav), str(dest))
found.append(Stem(name=name, path=str(dest.relative_to(catch_dir))))
return found
def run_composite(self, catch, workspace, source, raw_out, stems_dir,
progress, log, **opts) -> list[Stem]:
raise NotImplementedError raise NotImplementedError
...@@ -47,8 +66,6 @@ class DemucsBackend(Backend): ...@@ -47,8 +66,6 @@ class DemucsBackend(Backend):
name = "demucs" name = "demucs"
default_model = "htdemucs" default_model = "htdemucs"
# 4-stem variants (htdemucs_ft = fine-tuned, higher SDR; 6s adds guitar+piano).
# A drop-in stem-quality A/B — no new backend needed (#9).
models = ["htdemucs", "htdemucs_ft", "hdemucs_mmi", "htdemucs_6s"] models = ["htdemucs", "htdemucs_ft", "hdemucs_mmi", "htdemucs_6s"]
@property @property
...@@ -80,23 +97,112 @@ class DemucsBackend(Backend): ...@@ -80,23 +97,112 @@ class DemucsBackend(Backend):
class RoformerBackend(Backend): class RoformerBackend(Backend):
"""engine2 (stub): BS-/Mel-Band Roformer via `audio-separator`. """engine2: BS-/Mel-Band Roformer via `audio-separator` (py3.12 venv).
Wiring deferred — see tools/foundry/README.md "engine2". Registered now so A 2-STEM vocal/instrumental SOTA (spike #8: fits 6 GB, ~0.55× realtime). Not a
the registry, CLI `--backend`, and GUI selector already know it exists. 4-stem replacement for demucs — see MultiModelBackend for the 4-stem merge.
""" """
name = "roformer" name = "roformer"
default_model = "model_bs_roformer_ep_317_sdr_12.9755" default_model = "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
models = ["model_bs_roformer_ep_317_sdr_12.9755.ckpt",
"melband_roformer_inst_v2.ckpt",
"model_mel_band_roformer_ep_3005_sdr_11.4360.ckpt"]
produces = ("vocals", "instrumental")
def available(self) -> tuple[bool, str]: @property
return False, ( def bin(self) -> str:
"engine2 not wired yet — needs an `audio-separator` venv + a VRAM " return os.environ.get(
"spike on the 6 GB card. See README 'engine2'." "FOUNDRY_AUDIOSEP",
str(Path.home() / ".virtualenvs" / "audio-separator" / "bin" / "audio-separator"),
) )
def available(self) -> tuple[bool, str]:
if Path(self.bin).exists():
return True, self.bin
return False, (f"audio-separator not found at {self.bin} — needs the py3.12 "
"venv (py3.14 breaks beartype). See README 'engine2'.")
def build_cmd(self, source: Path, outdir: Path, **opts) -> list[str]:
model = opts.get("model") or self.default_model
outdir.mkdir(parents=True, exist_ok=True)
return [self.bin, str(source), "--model_filename", model,
"--output_dir", str(outdir), "--output_format", "WAV",
"--log_level", "WARNING"]
def stem_dir(self, source: Path, outdir: Path, model: str) -> Path:
return Path(outdir) # audio-separator writes straight into --output_dir
def normalize(self, src_stems: Path, stems_dir: Path, catch_dir: Path) -> list[Stem]:
# audio-separator names files "<src>_(Vocals)_<model…>.wav" — glob by tag.
found: list[Stem] = []
for tag, name in (("Vocals", "vocals"), ("Instrumental", "instrumental")):
hits = glob.glob(str(src_stems / f"*({tag})*.wav"))
if hits:
dest = stems_dir / f"{name}.wav"
shutil.move(hits[0], str(dest))
found.append(Stem(name=name, path=str(dest.relative_to(catch_dir))))
return found
class MultiModelBackend(Backend):
"""engine2 flagship — PLN's "merge of stems": Roformer vocals + demucs rest.
Cascade: Roformer isolates vocals + instrumental (SOTA vocal SDR), then demucs
runs on the INSTRUMENTAL (vocals already gone → cleaner) for drums/bass/other.
Merge = Roformer vocals ⊕ demucs drums/bass/other in the standard 4-stem contract.
"""
name = "merge"
default_model = "roformer-vocals+demucs-rest"
composite = True
def available(self) -> tuple[bool, str]:
for sub in ("roformer", "demucs"):
ok, hint = REGISTRY[sub].available()
if not ok:
return False, f"merge needs {sub}: {hint}"
return True, "roformer (vocals) + demucs (drums/bass/other)"
def run_composite(self, catch, workspace, source, raw_out, stems_dir,
progress, log, **opts) -> list[Stem]:
rof, dem = REGISTRY["roformer"], REGISTRY["demucs"]
# 1. Roformer → vocals + instrumental
log("⚙ merge 1/2: roformer (vocals)")
rof_out = raw_out / "roformer"
_run_cmd(rof.build_cmd(source, rof_out, model=rof.default_model), progress, log)
rof_stems = rof.normalize(rof.stem_dir(source, rof_out, rof.default_model),
stems_dir, catch.dir(workspace))
inst = next((stems_dir / "instrumental.wav" for s in rof_stems
if s.name == "instrumental"), None)
vocals = [s for s in rof_stems if s.name == "vocals"]
if not inst or not inst.exists():
raise RuntimeError("merge: roformer produced no instrumental to cascade")
# 2. demucs on the instrumental → drums/bass/other (discard its vocals)
log("⚙ merge 2/2: demucs on the instrumental (drums/bass/other)")
dmodel = opts.get("model") or "htdemucs"
if dmodel not in dem.models:
dmodel = "htdemucs"
dem_out = raw_out / "demucs"
_run_cmd(dem.build_cmd(inst, dem_out, model=dmodel,
shifts=opts.get("shifts", 2)), progress, log)
dsrc = dem.stem_dir(inst, dem_out, dmodel)
found: list[Stem] = list(vocals)
for name in ("drums", "bass", "other"):
wav = dsrc / f"{name}.wav"
if wav.exists():
dest = stems_dir / f"{name}.wav"
shutil.move(str(wav), str(dest))
found.append(Stem(name=name, path=str(dest.relative_to(catch.dir(workspace)))))
inst.unlink(missing_ok=True) # the instrumental was a means, not a stem
return found
REGISTRY: dict[str, Backend] = {b.name: b for b in (DemucsBackend(), RoformerBackend())} REGISTRY: dict[str, Backend] = {
b.name: b for b in (DemucsBackend(), RoformerBackend(), MultiModelBackend())
}
def get_backend(name: str) -> Backend: def get_backend(name: str) -> Backend:
...@@ -105,6 +211,22 @@ def get_backend(name: str) -> Backend: ...@@ -105,6 +211,22 @@ def get_backend(name: str) -> Backend:
return REGISTRY[name] return REGISTRY[name]
def _run_cmd(cmd: list[str], progress, log) -> None:
"""Run a separation subprocess, forwarding best-effort tqdm percent to progress."""
log(f" $ {' '.join(cmd)}")
proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, text=True, bufsize=1)
last = -1
for line in proc.stderr or []:
m = _PCT.search(line)
if m and progress:
pct = int(m.group(1))
if pct != last:
progress(pct)
last = pct
if proc.wait() != 0:
raise RuntimeError(f"{cmd[0]} exited {proc.returncode}")
def separate( def separate(
catch: Catch, catch: Catch,
workspace: Path, workspace: Path,
...@@ -127,36 +249,22 @@ def separate( ...@@ -127,36 +249,22 @@ def separate(
model = opts.pop("model", None) or be.default_model model = opts.pop("model", None) or be.default_model
raw_out = catch_dir / "_separated" raw_out = catch_dir / "_separated"
stems_dir = catch_dir / "stems"
stems_dir.mkdir(exist_ok=True)
if be.composite:
log(f"⚙ {backend}: {model}")
found = be.run_composite(catch, workspace, source, raw_out, stems_dir,
progress, log, model=model, **opts)
else:
cmd = be.build_cmd(source, raw_out, model=model, **opts) cmd = be.build_cmd(source, raw_out, model=model, **opts)
log(f"⚙ {backend}/{model}: {' '.join(cmd)}") log(f"⚙ {backend}/{model}: {' '.join(cmd)}")
_run_cmd(cmd, progress, log)
found = be.normalize(be.stem_dir(source, raw_out, model), stems_dir, catch_dir)
proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, text=True, bufsize=1)
last = -1
for line in proc.stderr or []:
m = _PCT.search(line)
if m:
pct = int(m.group(1))
if pct != last and progress:
progress(pct)
last = pct
if proc.wait() != 0:
raise RuntimeError(f"{backend} exited {proc.returncode}")
# Normalize backend output → catch_dir/stems/{name}.wav
src_stems = be.stem_dir(source, raw_out, model)
stems_dir = catch_dir / "stems"
stems_dir.mkdir(exist_ok=True)
found: list[Stem] = []
for name in STEMS:
wav = src_stems / f"{name}.wav"
if wav.exists():
dest = stems_dir / f"{name}.wav"
shutil.move(str(wav), str(dest))
found.append(Stem(name=name, path=str(dest.relative_to(catch_dir))))
shutil.rmtree(raw_out, ignore_errors=True) shutil.rmtree(raw_out, ignore_errors=True)
if not found: if not found:
raise RuntimeError(f"no stems produced under {src_stems}") raise RuntimeError(f"{backend} produced no stems")
catch.backend, catch.model, catch.stems = backend, model, found catch.backend, catch.model, catch.stems = backend, model, found
catch.save(workspace) catch.save(workspace)
log(f"✓ {len(found)} stems → {stems_dir}") log(f"✓ {len(found)} stems → {stems_dir}")
......
...@@ -61,8 +61,21 @@ def test_catch_save_load(tmp_path): ...@@ -61,8 +61,21 @@ def test_catch_save_load(tmp_path):
# ── backend registry ───────────────────────────────────────────────────── # ── backend registry ─────────────────────────────────────────────────────
def test_registry_has_both_engines(): def test_registry_has_the_engines():
assert set(S.REGISTRY) == {"demucs", "roformer"} assert set(S.REGISTRY) == {"demucs", "roformer", "merge"}
def test_roformer_produces_two_stems_and_builds_cmd():
be = S.get_backend("roformer")
assert be.produces == ("vocals", "instrumental")
cmd = be.build_cmd(Path("/w/s/source.webm"), Path("/tmp/o"), model="m.ckpt")
assert "--model_filename" in cmd and "m.ckpt" in cmd and "--output_dir" in cmd
def test_merge_is_composite_producing_four_stems():
be = S.get_backend("merge")
assert be.composite is True
assert be.produces == ("drums", "bass", "other", "vocals")
def test_demucs_cmd_build(): def test_demucs_cmd_build():
...@@ -81,9 +94,11 @@ def test_demucs_stem_dir(): ...@@ -81,9 +94,11 @@ def test_demucs_stem_dir():
assert d == Path("/w/s/_separated/htdemucs/source") assert d == Path("/w/s/_separated/htdemucs/source")
def test_roformer_registered_but_unavailable(): def test_backend_available_returns_status_and_hint():
ok, hint = S.get_backend("roformer").available() # availability depends on which venvs exist on the box; the contract is (bool, str)
assert ok is False and "engine2" in hint for name in ("demucs", "roformer", "merge"):
ok, hint = S.get_backend(name).available()
assert isinstance(ok, bool) and isinstance(hint, str) and hint
def test_unknown_backend_raises(): def test_unknown_backend_raises():
......
...@@ -166,7 +166,10 @@ function catchCard(c){ ...@@ -166,7 +166,10 @@ function catchCard(c){
`<div class="stem"><span class="tag" style="color:var(--mute)">original</span> `<div class="stem"><span class="tag" style="color:var(--mute)">original</span>
<audio controls preload="metadata" src="/media/${c.slug}/${c.source_path}"></audio></div>`); <audio controls preload="metadata" src="/media/${c.slug}/${c.source_path}"></audio></div>`);
if(c.separated){ if(c.separated){
STEMS.forEach(name=>{ const s=c.stems.find(x=>x.name===name); if(s) el.appendChild(stemRow(c.slug,s)); }); // render whatever stems this backend produced (demucs=4, roformer=2, merge=4),
// canonical roles first then any extras (e.g. roformer's instrumental)
const ord=s=>{const i=STEMS.indexOf(s.name);return i<0?99:i;};
[...c.stems].sort((a,b)=>ord(a)-ord(b)).forEach(s=>el.appendChild(stemRow(c.slug,s)));
const lp=document.createElement("div"); lp.className="loops"; lp.id="lp-"+c.slug; const lp=document.createElement("div"); lp.className="loops"; lp.id="lp-"+c.slug;
lp.innerHTML=`<div class="loops-head"><span class="lbl">loops</span> lp.innerHTML=`<div class="loops-head"><span class="lbl">loops</span>
<select class="mode" data-mode="${c.slug}" title="loop vs chop mode"> <select class="mode" data-mode="${c.slug}" title="loop vs chop mode">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment