feat: add target-and-remainder stem extraction

This commit is contained in:
Mikei386
2026-09-09 01:00:01 +02:00
parent 0f0e77928a
commit 2342495d24
4 changed files with 85 additions and 26 deletions
+7 -6
View File
@@ -36,12 +36,13 @@ Docker-Netz `mike-ai-music` auf `http://music-worker:7860` zu.
Im Trennmodus öffnet das Dashboard die private Athena-Oberfläche unter Im Trennmodus öffnet das Dashboard die private Athena-Oberfläche unter
`http://192.168.1.212:8007`. Sie nimmt WAV, FLAC, MP3, M4A und weitere `http://192.168.1.212:8007`. Sie nimmt WAV, FLAC, MP3, M4A und weitere
übliche Formate an und liefert verlustfreie FLAC-Spuren als ZIP. Verfügbar sind übliche Formate an. Gewählt wird die herauszulösende Quelle: Gesang,
die spezialisierte Gesangstrennung (`vocals`, `instrumental`), vier Spuren Schlagzeug, Bass, Gitarre oder Piano. Das ZIP enthält genau diese Zielspur und
(`vocals`, `drums`, `bass`, `other`) und experimentell sechs Spuren eine zweite FLAC-Datei mit dem vollständigen Rest ohne die Zielspur. Gesang
(`vocals`, `drums`, `bass`, `guitar`, `piano`, `other`). Grundlage ist nutzt BS-RoFormer Viperx 1297, Schlagzeug/Bass `htdemucs_ft` und
`audio-separator` 0.47.0 mit BS-RoFormer Viperx 1297, `htdemucs_ft` und Gitarre/Piano experimentell `htdemucs_6s`. Grundlage ist `audio-separator`
`htdemucs_6s`. 0.47.0. Die ältere API-Auswahl kompletter 2-/4-/6-Stem-Sätze bleibt
rückwärtskompatibel.
Hermes benötigt dafür kein Plugin. Exakt eingegebene Steuerbefehle werden vom Hermes benötigt dafür kein Plugin. Exakt eingegebene Steuerbefehle werden vom
Router lokal beantwortet, auch wenn gerade kein LLM geladen ist: Router lokal beantwortet, auch wenn gerade kein LLM geladen ist:
@@ -1,15 +1,18 @@
# Athena Stem Separator # Athena Stem Separator
Exklusiver dritter Athena-Betriebsmodus für lokale Zwei- bis Sechs-Spur-Trennung. Exklusiver dritter Athena-Betriebsmodus zum gezielten Herauslösen einer Quelle.
Der Download enthält immer die Zielspur und eine zweite Spur mit dem kompletten
Rest ohne dieses Ziel.
- Engine: `audio-separator` 0.47.0 (MIT) - Engine: `audio-separator` 0.47.0 (MIT)
- **Gesang / Instrumental:** BS-RoFormer Viperx 1297, - **Gesang / Instrumental:** BS-RoFormer Viperx 1297,
`model_bs_roformer_ep_317_sdr_12.9755.ckpt`; Vocal SDR 12,9, `model_bs_roformer_ep_317_sdr_12.9755.ckpt`; Vocal SDR 12,9,
Instrumental SDR 17,0. Instrumental SDR 17,0.
- **4 Spuren:** `htdemucs_ft.yaml` mit Gesang, Schlagzeug, Bass und Rest. - **Schlagzeug oder Bass:** `htdemucs_ft.yaml`; die nicht gewählten Stems werden
- **6 Spuren (experimentell):** `htdemucs_6s.yaml` ergänzt gemeinsame Gitarre zu einer gemeinsamen Restspur summiert.
und Piano. Die Instrumentqualität liegt unter der spezialisierten - **Gitarre oder Piano (experimentell):** `htdemucs_6s.yaml`; auch hier werden
Zwei-Spur-Trennung. alle übrigen Stems wieder zur Restspur zusammengesetzt. Die Instrumentqualität
liegt unter der spezialisierten Gesangstrennung.
- GPU: RTX 5080; LLM, Bildmodelle, TTS und ACE-Step sind dabei verriegelt. - GPU: RTX 5080; LLM, Bildmodelle, TTS und ACE-Step sind dabei verriegelt.
- Privat erreichbar: `http://192.168.1.212:8007/` - Privat erreichbar: `http://192.168.1.212:8007/`
@@ -19,5 +22,7 @@ werden nach dem ZIP-Download entfernt. Eigene Spuren für E-/Akustikgitarre,
Synthesizer und Streicher sind bewusst noch nicht angeboten: Dafür braucht es Synthesizer und Streicher sind bewusst noch nicht angeboten: Dafür braucht es
weitere Zielmodelle; sie aus `other` umzubenennen wäre fachlich falsch. weitere Zielmodelle; sie aus `other` umzubenennen wäre fachlich falsch.
Die API erwartet `multipart/form-data` mit `file` und optional `mode`: Die API erwartet `multipart/form-data` mit `file` und optional `target`:
`vocals` (Standard), `four_stem` oder `six_stem`. `vocals` (Standard), `drums`, `bass`, `guitar` oder `piano`. Das ältere Feld
`mode` mit `vocals`, `four_stem` oder `six_stem` bleibt für vorhandene Clients
erhalten und liefert weiterhin alle Modell-Stems.
@@ -27,6 +27,13 @@ MODES = {
"four_stem": {"model": "htdemucs_ft.yaml", "stems": ("vocals", "drums", "bass", "other"), "archive": "athena-4-stems.zip", "engine": "demucs"}, "four_stem": {"model": "htdemucs_ft.yaml", "stems": ("vocals", "drums", "bass", "other"), "archive": "athena-4-stems.zip", "engine": "demucs"},
"six_stem": {"model": "htdemucs_6s.yaml", "stems": ("vocals", "drums", "bass", "guitar", "piano", "other"), "archive": "athena-6-stems-experimental.zip", "engine": "demucs"}, "six_stem": {"model": "htdemucs_6s.yaml", "stems": ("vocals", "drums", "bass", "guitar", "piano", "other"), "archive": "athena-6-stems-experimental.zip", "engine": "demucs"},
} }
TARGETS = {
"vocals": {"mode": "vocals", "stem": "vocals", "remainder": "instrumental", "archive": "athena-gesang-und-rest.zip", "rest_file": "instrumental.flac"},
"drums": {"mode": "four_stem", "stem": "drums", "archive": "athena-schlagzeug-und-rest.zip", "rest_file": "rest-ohne-schlagzeug.flac"},
"bass": {"mode": "four_stem", "stem": "bass", "archive": "athena-bass-und-rest.zip", "rest_file": "rest-ohne-bass.flac"},
"guitar": {"mode": "six_stem", "stem": "guitar", "archive": "athena-gitarre-und-rest.zip", "rest_file": "rest-ohne-gitarre.flac"},
"piano": {"mode": "six_stem", "stem": "piano", "archive": "athena-piano-und-rest.zip", "rest_file": "rest-ohne-piano.flac"},
}
app = FastAPI(title="Athena Stem Separator", version="2.0") app = FastAPI(title="Athena Stem Separator", version="2.0")
@@ -43,6 +50,7 @@ def health() -> dict:
"status": "ok" if all(available.values()) else "starting", "status": "ok" if all(available.values()) else "starting",
"models": {name: mode["model"] for name, mode in MODES.items()}, "models": {name: mode["model"] for name, mode in MODES.items()},
"models_ready": available, "models_ready": available,
"targets": list(TARGETS),
"busy": SEPARATION_LOCK.locked(), "busy": SEPARATION_LOCK.locked(),
"uptime_seconds": round(time.time() - STARTED, 1), "uptime_seconds": round(time.time() - STARTED, 1),
} }
@@ -80,11 +88,34 @@ def _stem_name(path: Path, expected: tuple[str, ...]) -> str | None:
return None return None
def _mix_remainder(stems: list[Path], output_path: Path) -> None:
args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
for stem in stems:
args.extend(["-i", str(stem)])
inputs = "".join(f"[{index}:a]" for index in range(len(stems)))
args.extend([
"-filter_complex", f"{inputs}amix=inputs={len(stems)}:normalize=0:dropout_transition=0[rest]",
"-map", "[rest]", "-ar", "44100", "-c:a", "flac", str(output_path),
])
completed = subprocess.run(args, capture_output=True, text=True, timeout=1800)
if completed.returncode:
detail = (completed.stderr or completed.stdout or "unknown ffmpeg error")[-4000:]
raise RuntimeError(f"Restspur konnte nicht erzeugt werden: {detail}")
@app.post("/v1/separate") @app.post("/v1/separate")
async def separate(file: UploadFile = File(...), mode: str = Form("vocals")) -> FileResponse: async def separate(
selected_mode = MODES.get(mode) file: UploadFile = File(...),
target: str | None = Form(None),
mode: str | None = Form(None),
) -> FileResponse:
selected_target = TARGETS.get(target) if target else None
if target and selected_target is None:
raise HTTPException(422, f"Unbekannte Zielspur: {target}")
selected_mode_name = selected_target["mode"] if selected_target else (mode or "vocals")
selected_mode = MODES.get(selected_mode_name)
if selected_mode is None: if selected_mode is None:
raise HTTPException(422, f"Unbekannter Trennmodus: {mode}") raise HTTPException(422, f"Unbekannter Trennmodus: {selected_mode_name}")
suffix = Path(file.filename or "upload.wav").suffix.lower() suffix = Path(file.filename or "upload.wav").suffix.lower()
if suffix not in ALLOWED: if suffix not in ALLOWED:
raise HTTPException(415, "Dieses Audioformat wird nicht unterstützt.") raise HTTPException(415, "Dieses Audioformat wird nicht unterstützt.")
@@ -114,14 +145,30 @@ async def separate(file: UploadFile = File(...), mode: str = Form("vocals")) ->
if missing: if missing:
found = ", ".join(stem.name for stem in stems) or "keine" found = ", ".join(stem.name for stem in stems) or "keine"
raise RuntimeError(f"Fehlende Spuren: {', '.join(missing)}; gefunden: {found}") raise RuntimeError(f"Fehlende Spuren: {', '.join(missing)}; gefunden: {found}")
archive = job / selected_mode["archive"] archive_name = selected_target["archive"] if selected_target else selected_mode["archive"]
archive = job / archive_name
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as bundle: with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as bundle:
if selected_target:
target_stem = selected_target["stem"]
bundle.write(recognized[target_stem], f"{target_stem}.flac")
if "remainder" in selected_target:
remainder = recognized[selected_target["remainder"]]
else:
remainder = job / selected_target["rest_file"]
await asyncio.to_thread(
_mix_remainder,
[recognized[stem] for stem in expected if stem != target_stem],
remainder,
)
bundle.write(remainder, selected_target["rest_file"])
else:
# Rückwärtskompatibilität für bestehende API-Clients.
for stem in expected: for stem in expected:
bundle.write(recognized[stem], f"{stem}.flac") bundle.write(recognized[stem], f"{stem}.flac")
return FileResponse( return FileResponse(
archive, archive,
media_type="application/zip", media_type="application/zip",
filename=selected_mode["archive"], filename=archive_name,
background=BackgroundTask(_cleanup, job), background=BackgroundTask(_cleanup, job),
) )
except HTTPException: except HTTPException:
@@ -1,9 +1,15 @@
<!doctype html> <!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Athena · Spuren trennen</title><style> <title>Athena · Spuren herauslösen</title><style>
:root{color-scheme:dark;--bg:#07111c;--card:#101d2b;--line:#26384b;--cyan:#48d7f5;--mint:#63e6be;--text:#ecf5ff;--muted:#91a4b7}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#142a42 0,#07111c 42%);font:16px system-ui,sans-serif;color:var(--text);min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(820px,100%);padding:32px;border:1px solid var(--line);border-radius:22px;background:rgba(16,29,43,.96);box-shadow:0 25px 70px #0008}.eyebrow{color:var(--cyan);font-weight:800;letter-spacing:.14em;text-transform:uppercase;font-size:12px}h1{font-size:clamp(30px,5vw,52px);margin:.3em 0 .15em}p{color:var(--muted);line-height:1.6}.modes{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:24px 0}.mode{display:block;border:1px solid var(--line);border-radius:14px;padding:14px;cursor:pointer}.mode:has(input:checked){border-color:var(--cyan);background:#48d7f510}.mode input{display:none}.mode b,.mode span{display:block}.mode span{color:var(--muted);font-size:13px;margin-top:5px;line-height:1.35}.drop{display:block;margin:20px 0;padding:36px 24px;border:2px dashed #3f5a72;border-radius:18px;text-align:center;cursor:pointer;transition:.2s}.drop:hover,.drop.drag{border-color:var(--cyan);background:#48d7f50b}.drop input{display:none}.file{color:var(--mint);font-weight:700;margin-top:8px}button{width:100%;border:0;border-radius:13px;padding:15px;font-weight:800;font-size:16px;background:linear-gradient(90deg,var(--cyan),var(--mint));color:#05202a;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.status{min-height:28px;margin-top:18px;color:var(--muted)}.bar{height:7px;background:#07111c;border-radius:9px;overflow:hidden;margin-top:12px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--mint));transition:.4s}.run .fill{width:85%;animation:pulse 1.5s infinite alternate}@keyframes pulse{to{opacity:.45}}small{display:block;color:#71879a;margin-top:20px}@media(max-width:680px){.modes{grid-template-columns:1fr}} :root{color-scheme:dark;--bg:#07111c;--card:#101d2b;--line:#26384b;--cyan:#48d7f5;--mint:#63e6be;--text:#ecf5ff;--muted:#91a4b7}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#142a42 0,#07111c 42%);font:16px system-ui,sans-serif;color:var(--text);min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(880px,100%);padding:32px;border:1px solid var(--line);border-radius:22px;background:rgba(16,29,43,.96);box-shadow:0 25px 70px #0008}.eyebrow{color:var(--cyan);font-weight:800;letter-spacing:.14em;text-transform:uppercase;font-size:12px}h1{font-size:clamp(30px,5vw,52px);margin:.3em 0 .15em}p{color:var(--muted);line-height:1.6}.targets{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin:24px 0}.target{display:block;border:1px solid var(--line);border-radius:14px;padding:14px 10px;text-align:center;cursor:pointer}.target:has(input:checked){border-color:var(--cyan);background:#48d7f510;box-shadow:0 0 0 1px #48d7f528}.target input{display:none}.target b,.target span{display:block}.target span{color:var(--muted);font-size:12px;margin-top:5px;line-height:1.35}.drop{display:block;margin:20px 0;padding:36px 24px;border:2px dashed #3f5a72;border-radius:18px;text-align:center;cursor:pointer;transition:.2s}.drop:hover,.drop.drag{border-color:var(--cyan);background:#48d7f50b}.drop input{display:none}.file{color:var(--mint);font-weight:700;margin-top:8px}button{width:100%;border:0;border-radius:13px;padding:15px;font-weight:800;font-size:16px;background:linear-gradient(90deg,var(--cyan),var(--mint));color:#05202a;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.status{min-height:28px;margin-top:18px;color:var(--muted)}.bar{height:7px;background:#07111c;border-radius:9px;overflow:hidden;margin-top:12px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--mint));transition:.4s}.run .fill{width:85%;animation:pulse 1.5s infinite alternate}@keyframes pulse{to{opacity:.45}}small{display:block;color:#71879a;margin-top:20px}@media(max-width:760px){.targets{grid-template-columns:repeat(2,1fr)}}
</style></head><body><main class="card"><div class="eyebrow">Athena Audio Lab</div><h1>Instrumente und Stimmen trennen</h1><p>Wähle zwischen der besonders sauberen Gesangstrennung und zusammengehörigen Mehrspur-Modellen.</p> </style></head><body><main class="card"><div class="eyebrow">Athena Audio Lab</div><h1>Was möchtest du herauslösen?</h1><p>Der Download enthält immer die gewählte Spur separat und zusätzlich den vollständigen Rest ohne diese Spur.</p>
<div class="modes"><label class="mode"><input type="radio" name="mode" value="vocals" checked><b>Gesang · beste Qualität</b><span>Vocals + Instrumental<br>BS‑RoFormer</span></label><label class="mode"><input type="radio" name="mode" value="four_stem"><b>4 Spuren</b><span>Gesang, Schlagzeug, Bass, Rest<br>HTDemucs FT</span></label><label class="mode"><input type="radio" name="mode" value="six_stem"><b>6 Spuren · experimentell</b><span>Zusätzlich Gitarre und Piano<br>HTDemucs 6s</span></label></div> <div class="targets">
<label class="drop" id="drop">Audio auswählen oder hier ablegen<input id="file" type="file" accept="audio/*"><div class="file" id="name">Noch keine Datei gewählt</div></label><button id="start" disabled>Spuren erzeugen</button><div class="status" id="status">Bereit.</div><div class="bar" id="bar"><div class="fill"></div></div><small>Alles läuft lokal auf Athena. Synthesizer, Streicher sowie elektrische und akustische Gitarre separat benötigen zusätzliche Spezialmodelle und sind noch nicht freigeschaltet.</small></main><script> <label class="target"><input type="radio" name="target" value="vocals" checked><b>Gesang</b><span>BS‑RoFormer<br>beste Qualität</span></label>
const file=document.querySelector('#file'),drop=document.querySelector('#drop'),name=document.querySelector('#name'),start=document.querySelector('#start'),status=document.querySelector('#status'),bar=document.querySelector('#bar');let selected;const info={vocals:['athena-vocals-instrumental.zip','vocals.flac und instrumental.flac'],four_stem:['athena-4-stems.zip','vier Spuren'],six_stem:['athena-6-stems-experimental.zip','sechs Spuren']};function choose(f){selected=f;name.textContent=f?`${f.name} · ${(f.size/1048576).toFixed(1)} MiB`:'Noch keine Datei gewählt';start.disabled=!f}file.onchange=()=>choose(file.files[0]);drop.ondragover=e=>{e.preventDefault();drop.classList.add('drag')};drop.ondragleave=()=>drop.classList.remove('drag');drop.ondrop=e=>{e.preventDefault();drop.classList.remove('drag');choose(e.dataTransfer.files[0])};start.onclick=async()=>{const mode=document.querySelector('input[name=mode]:checked').value;start.disabled=true;bar.classList.add('run');status.textContent='Modell trennt den Titel – das kann einige Minuten dauern …';let body=new FormData();body.append('file',selected);body.append('mode',mode);try{let r=await fetch('/v1/separate',{method:'POST',body});if(!r.ok)throw Error((await r.json()).detail||`HTTP ${r.status}`);let blob=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=info[mode][0];a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status.textContent=`Fertig – ZIP mit ${info[mode][1]} wurde geladen.`}catch(e){status.textContent=`Fehler: ${e.message}`}finally{bar.classList.remove('run');start.disabled=false}}; <label class="target"><input type="radio" name="target" value="drums"><b>Schlagzeug</b><span>HTDemucs FT</span></label>
<label class="target"><input type="radio" name="target" value="bass"><b>Bass</b><span>HTDemucs FT</span></label>
<label class="target"><input type="radio" name="target" value="guitar"><b>Gitarre</b><span>HTDemucs 6s<br>experimentell</span></label>
<label class="target"><input type="radio" name="target" value="piano"><b>Piano</b><span>HTDemucs 6s<br>experimentell</span></label>
</div>
<label class="drop" id="drop">Audio auswählen oder hier ablegen<input id="file" type="file" accept="audio/*"><div class="file" id="name">Noch keine Datei gewählt</div></label><button id="start" disabled>Ausgewählte Spur und Rest erzeugen</button><div class="status" id="status">Bereit.</div><div class="bar" id="bar"><div class="fill"></div></div><small>Alles läuft lokal auf Athena. Synthesizer, Streicher sowie elektrische und akustische Gitarre separat benötigen zusätzliche Spezialmodelle.</small></main><script>
const file=document.querySelector('#file'),drop=document.querySelector('#drop'),name=document.querySelector('#name'),start=document.querySelector('#start'),status=document.querySelector('#status'),bar=document.querySelector('#bar');let selected;const names={vocals:'gesang',drums:'schlagzeug',bass:'bass',guitar:'gitarre',piano:'piano'};function choose(f){selected=f;name.textContent=f?`${f.name} · ${(f.size/1048576).toFixed(1)} MiB`:'Noch keine Datei gewählt';start.disabled=!f}file.onchange=()=>choose(file.files[0]);drop.ondragover=e=>{e.preventDefault();drop.classList.add('drag')};drop.ondragleave=()=>drop.classList.remove('drag');drop.ondrop=e=>{e.preventDefault();drop.classList.remove('drag');choose(e.dataTransfer.files[0])};start.onclick=async()=>{const target=document.querySelector('input[name=target]:checked').value;start.disabled=true;bar.classList.add('run');status.textContent='Modell löst die gewählte Spur heraus – das kann einige Minuten dauern …';let body=new FormData();body.append('file',selected);body.append('target',target);try{let r=await fetch('/v1/separate',{method:'POST',body});if(!r.ok)throw Error((await r.json()).detail||`HTTP ${r.status}`);let blob=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`athena-${names[target]}-und-rest.zip`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status.textContent='Fertig – ZIP mit der ausgewählten Spur und dem Rest wurde geladen.'}catch(e){status.textContent=`Fehler: ${e.message}`}finally{bar.classList.remove('run');start.disabled=false}};
</script></body></html> </script></body></html>