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