Add BS-RoFormer vocal separation mode

This commit is contained in:
Mikei386
2026-09-08 19:23:29 +02:00
parent a4e894fe70
commit 0069b61dbb
15 changed files with 421 additions and 52 deletions
+4 -3
View File
@@ -15,8 +15,8 @@ Bild- und Sprachausgabe. **Hermes und die Fach-MCPs laufen auf Unraid.**
- Qwen3-TTS 1.7B auf der RTX 3060 mit Piper als CPU-Fallback
- Whisper.cpp `large-v3-turbo` auf der CPU für lokale deutsche Spracherkennung
- Live-Dashboard mit 21 Tagen Detailhistorie auf Port 8099
- Dashboard-Umschaltung zwischen LLM-Betrieb und ACE-Step-Musikstudio mit
persistenter `fspecii/ace-step-ui`-Bibliothek
- Dashboard-Umschaltung zwischen LLM-Betrieb, ACE-Step-Musikstudio und
BS-RoFormer-Stimmtrennung
- Portainer CE als optionale Container-Ansicht auf Port 9443
- WireGuard-Gateway, Datenbackup und Athena-Operator
- keine produktive Hermes-, OpenWebUI- oder portable Fach-MCP-Instanz
@@ -124,9 +124,10 @@ Details, Installation, Prüfung und Rollback stehen in
- Athena-Dashboard: `http://192.168.1.212:8099`
- Musikstudio, Original UI (stabil): `http://192.168.1.212:7862`
- Musikstudio, Community UI (experimentell): `http://192.168.1.212:7861`
- Stimmen trennen (BS-RoFormer): `http://192.168.1.212:8007`
Der Betriebsmodus lässt sich dort direkt umschalten. In Hermes funktionieren
außerdem `/athena music`, `/athena llm` und `/athena status`; Details stehen in
außerdem `/athena music`, `/athena stems`, `/athena llm` und `/athena status`; Details stehen in
[docs/OPERATING_MODES.md](docs/OPERATING_MODES.md).
Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über
+2
View File
@@ -570,6 +570,7 @@ services:
RESTORE_WORKER: restore
TTS_WORKER: qwen3
MUSIC_WORKER: acestep
SEPARATOR_WORKER: bs-roformer
networks: [control]
security_opt: ["no-new-privileges:true"]
healthcheck:
@@ -858,6 +859,7 @@ services:
ROUTER_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
MUSIC_COMMUNITY_UI_URL: "${MUSIC_COMMUNITY_UI_URL:-http://192.168.1.212:7861/}"
MUSIC_ORIGINAL_UI_URL: "${MUSIC_ORIGINAL_UI_URL:-http://192.168.1.212:7862/}"
SEPARATOR_UI_URL: "${SEPARATOR_UI_URL:-http://192.168.1.212:8007/}"
HOST_PROC: /host/proc
HOST_DATA: /host/data
HOST_MODELS: /host/models
+33
View File
@@ -41,7 +41,40 @@ def music_item(state="exited"):
"Labels": {controller.MUSIC_LABEL_KEY: "acestep"}}
def separator_item(state="exited"):
return {"Id": "id-separator", "State": state,
"Labels": {controller.SEPARATOR_LABEL_KEY: "bs-roformer"}}
class ProfileControllerTests(unittest.TestCase):
def test_separator_start_exclusively_stops_gpu_workers(self):
profiles = {name: item(name) for name in controller.ALLOWED}
profiles["large"] = item("large", "running")
calls = []
def request(method, path):
calls.append((method, path))
return 204, b""
with patch.object(controller, "SEPARATOR_WORKER", "bs-roformer"), \
patch.object(controller, "MUSIC_WORKER", "acestep"), \
patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "separator_container", return_value=separator_item()), \
patch.object(controller, "music_container", return_value=music_item("running")), \
patch.object(controller, "image_containers", return_value=[image_item("running")]), \
patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request):
result = controller.set_separator_worker(True)
self.assertEqual(result, {"separator_worker": "bs-roformer", "state": "running"})
self.assertEqual(calls, [
("POST", "/containers/id-large/stop?t=120"),
("POST", "/containers/id-flux/stop?t=20"),
("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-music/stop?t=30"),
("POST", "/containers/id-separator/start"),
])
def test_music_start_exclusively_stops_gpu_workers(self):
profiles = {name: item(name) for name in controller.ALLOWED}
profiles["ultra"] = item("ultra", "running")
+20 -9
View File
@@ -1,18 +1,20 @@
# Athena-Betriebsmodi
Athena besitzt zwei gegenseitig exklusive Betriebsmodi:
Athena besitzt drei gegenseitig exklusive Betriebsmodi:
- `llm`: ein llama.cpp-Profil und Qwen3-TTS laufen; ACE-Step ist gestoppt.
- `music`: ACE-Step 1.5 XL-SFT läuft; alle LLM-, Bild- und TTS-Worker sind gestoppt.
- `llm`: ein llama.cpp-Profil und Qwen3-TTS laufen; Spezialdienste sind gestoppt.
- `music`: ACE-Step 1.5 XL-SFT läuft; alle LLM-, Bild-, TTS- und Separator-Worker sind gestoppt.
- `separation`: BS-RoFormer Viperx 1297 trennt Gesang und Instrumental;
LLM, Bild, TTS und ACE-Step sind gestoppt.
Die Zustandsmaschine lebt im Athena-Router. Das Dashboard und Chat-Clients wie
Hermes sind nur Bedienoberflächen derselben API. Der zuletzt aktive LLM-Modus
wird persistent gespeichert und beim Verlassen des Musikmodus wieder geladen.
wird persistent gespeichert und beim Verlassen eines Spezialmodus wieder geladen.
## Bedienung
Im Athena-Dashboard stehen die Schaltflächen **LLM-Betrieb** und
**Musikstudio starten** bereit. Im Musikmodus werden zwei Oberflächen angeboten:
Im Athena-Dashboard stehen **LLM-Betrieb**, **Musikstudio** und
**Stimmen trennen** bereit. Im Musikmodus werden zwei Oberflächen angeboten:
- **Original UI · stabil** öffnet die zum laufenden ACE-Step-Image gehörende
Gradio-Oberfläche. Sie ist für Cover, Remix und erweiterte Workflows der
@@ -32,11 +34,18 @@ veröffentlicht. `ace-step-ui` ist reproduzierbar auf Commit
`a1fdf91829ec6f7b98844f80e323529cd155dbf2` fixiert und greift intern über das
Docker-Netz `mike-ai-music` auf `http://music-worker:7860` zu.
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
übliche Formate an und liefert ein ZIP mit verlustfreien `vocals.flac` und
`instrumental.flac`. Grundlage ist `audio-separator` 0.47.0 mit
`model_bs_roformer_ep_317_sdr_12.9755.ckpt`.
Hermes benötigt dafür kein Plugin. Exakt eingegebene Steuerbefehle werden vom
Router lokal beantwortet, auch wenn gerade kein LLM geladen ist:
```text
/athena music
/athena stems
/athena llm
/athena status
```
@@ -46,17 +55,19 @@ Die HTTP-Schnittstelle verwendet authentifizierte Requests:
```text
GET /mode
POST /mode {"mode":"music"}
POST /mode {"mode":"separation"}
POST /mode {"mode":"llm"}
```
Der Wechsel läuft asynchron. Fortschritt und Fehler stehen unter `mode` in
`GET /status`. Der Profile-Controller akzeptiert ausschließlich den mit
`com.mike-ai.music-worker=acestep` markierten Container; freie Container- oder
Docker-Befehle werden nicht entgegengenommen.
`com.mike-ai.music-worker=acestep` beziehungsweise
`com.mike-ai.stem-separator=bs-roformer` markierten Container; freie
Container- oder Docker-Befehle werden nicht entgegengenommen.
## Wiederanlauf
Der Router speichert `mode`, `last_profile` und `return_profile` atomar. War
beim Router-Neustart der Musikmodus aktiv, startet er ACE-Step erneut. Beim
beim Router-Neustart ein Spezialmodus aktiv, startet er den passenden Worker erneut. Beim
Wechsel zurück wird das gespeicherte LLM-Profil semantisch auf Alias und
Kontextfenster geprüft, bevor Chat-Anfragen wieder freigegeben werden.
+1 -1
View File
@@ -9,7 +9,7 @@ im zentralen Register `TESTED_MODELS.md` dokumentiert wurde.
| Prioritaet | Aufgabe | Kandidat | Geplanter Betrieb | Status |
|---:|---|---|---|---|
| 1 | Musik erzeugen und bearbeiten | `ACE-Step 1.5 XL SFT` mit `acestep-5Hz-lm-1.7B` | exklusives On-Demand-Profil auf der RTX 5080; CPU-Offload; Qwen, Vision und TTS werden waehrenddessen entladen | **integriert; Klangabnahme laeuft** |
| 2 | Gesang und Instrumente trennen | BS-RoFormer Viperx 1297; alternativ MelBand-RoFormer, fuer Mehrspur `htdemucs_ft` | eigener Audio-Worker; GPU bevorzugt, CPU als langsamer Fallback | offen |
| 2 | Gesang und Instrumente trennen | BS-RoFormer Viperx 1297, `ep_317` | exklusiver Audio-Worker auf der RTX 5080; FLAC-Ausgabe; eigener Dashboard-Modus | **integriert; Qualitätstest läuft** |
| 3 | Voice Cloning | vorhandenes `Qwen3-TTS-12Hz-1.7B-Base` | bestehender TTS-Worker auf der RTX 3060; zunaechst den eingebauten 3-Sekunden-Klonpfad freilegen | offen |
| 4 | Objekte lokalisieren und zaehlen | Grounding DINO oder RF-DETR | optionaler Vision-Worker; normales Erkennen bleibt beim vorhandenen Qwen-Vision-Projektor | offen |
| 5 | Bildort schaetzen | GeoAgent 8B | exklusives Vision-Profil; Ergebnis nur als Wahrscheinlichkeitsrangliste | offen |
+6
View File
@@ -63,6 +63,12 @@ Titelgenerierung und Kontextkompression in Hermes.
|---|---|---|---|---|---|
| 08.09.2026 | `ACE-Step/acestep-v15-xl-sft` mit `acestep-5Hz-lm-1.7B`, offizielles ACE-Step-1.5-Image `sha256:95652cd780c78a1b1a7f6f0335530430f0ae53d96c7c12d59f9f39fa23d38567` | 30 s Instrumental, Thinking/LM aktiv, Batch 1, RTX 5080 16 GiB, automatischer CPU-Offload und INT8 Weight-only DiT | erfolgreich in 15,39 s: LM 8,00 s, DiT 7,39 s, MP3 0,82 s; PyTorch meldete maximal 9,38 GiB CUDA-Allokation; kein OOM/CUDA-Fehler | **Beta-Test bestanden**; Klangabnahme und Hermes-/Router-Integration noch offen | Athena: `/data/music/acestep/batch_1788873234/`; [SPECIALIZED_MODEL_ROADMAP.md](SPECIALIZED_MODEL_ROADMAP.md) |
## Audio-Trennung
| Datum | Modell | Test | Ergebnis | Status / Entscheidung | Beleg |
|---|---|---|---|---|---|
| 08.09.2026 | BS-RoFormer Viperx 1297, `model_bs_roformer_ep_317_sdr_12.9755.ckpt`, `audio-separator` 0.47.0 | 20-s-FLAC eines vorhandenen ACE-Step-Titels, RTX 5080, CUDA 12.8, ONNX Runtime GPU 1.22.0 | zwei gültige FLAC-Spuren mit jeweils exakt 20,0 s; Verarbeitung 19 s; Vocal-Datei 1,45 MB, Instrumental-Datei 3,84 MB | **technischer Ende-zu-Ende-Test bestanden**; Hörabnahme durch Nutzer offen | [bs-roformer-vocal-separation](../experiments/bs-roformer-vocal-separation/README.md) |
## Ablauf für zukünftige Kandidaten
1. Exakten Hugging-Face-/Ollama-Namen und Dateinamen in diesem Dokument suchen.
@@ -0,0 +1,22 @@
FROM pytorch/pytorch:2.7.1-cuda12.8-cudnn9-runtime@sha256:c16f4c749e2d9e96878875cdf6cc45cddda1d1a36fddd371dd6f2360f1b6e2a2
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential curl ffmpeg libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
RUN python -m pip install --no-cache-dir \
"audio-separator[gpu]==0.47.0" \
"onnxruntime-gpu==1.22.0" \
"fastapi==0.116.1" \
"python-multipart==0.0.20" \
"uvicorn[standard]==0.35.0"
WORKDIR /app
COPY app.py index.html ./
ENV MODEL_FILENAME=model_bs_roformer_ep_317_sdr_12.9755.ckpt \
MODEL_DIR=/models \
JOB_DIR=/data/jobs
EXPOSE 8080
CMD ["sh", "-c", "audio-separator --model_filename \"$MODEL_FILENAME\" --model_file_dir \"$MODEL_DIR\" --download_model_only && exec uvicorn app:app --host 0.0.0.0 --port 8080 --workers 1"]
@@ -0,0 +1,16 @@
# Athena Vocal Separator
Exklusiver dritter Athena-Betriebsmodus für lokale Zwei-Spur-Trennung in
`vocals.flac` und `instrumental.flac`.
- Engine: `audio-separator` 0.47.0 (MIT)
- Modell: BS-RoFormer Viperx 1297,
`model_bs_roformer_ep_317_sdr_12.9755.ckpt`
- Modellbewertung im audio-separator-Katalog: Vocal SDR 12,9,
Instrumental SDR 17,0
- GPU: RTX 5080; LLM, Bildmodelle, TTS und ACE-Step sind dabei verriegelt.
- Privat erreichbar: `http://192.168.1.212:8007/`
Das Modell wird beim ersten Start nach `/data/models/audio-separator`
heruntergeladen. Temporäre Jobs liegen unter `/data/audio/separation` und
werden nach dem ZIP-Download entfernt.
@@ -0,0 +1,124 @@
from __future__ import annotations
import asyncio
import os
import shutil
import subprocess
import tempfile
import time
import zipfile
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from starlette.background import BackgroundTask
MODEL = os.getenv("MODEL_FILENAME", "model_bs_roformer_ep_317_sdr_12.9755.ckpt")
MODEL_DIR = Path(os.getenv("MODEL_DIR", "/models"))
JOB_DIR = Path(os.getenv("JOB_DIR", "/data/jobs"))
MAX_UPLOAD = int(os.getenv("MAX_UPLOAD_BYTES", str(1024 ** 3)))
ALLOWED = {".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wma"}
SEPARATION_LOCK = asyncio.Lock()
STARTED = time.time()
app = FastAPI(title="Athena Vocal Separator", version="1.0")
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return Path("/app/index.html").read_text(encoding="utf-8")
@app.get("/health")
def health() -> dict:
checkpoint = MODEL_DIR / MODEL
return {
"status": "ok" if checkpoint.exists() else "starting",
"model": MODEL,
"model_ready": checkpoint.exists(),
"busy": SEPARATION_LOCK.locked(),
"uptime_seconds": round(time.time() - STARTED, 1),
}
def _cleanup(path: Path) -> None:
shutil.rmtree(path, ignore_errors=True)
def _run_separator(input_path: Path, output_dir: Path) -> None:
args = [
"audio-separator", str(input_path),
"--model_filename", MODEL,
"--model_file_dir", str(MODEL_DIR),
"--output_dir", str(output_dir),
"--output_format", "FLAC",
"--sample_rate", "44100",
"--use_soundfile",
"--use_autocast",
"--mdxc_segment_size", "256",
"--mdxc_overlap", "8",
"--mdxc_batch_size", "1",
]
completed = subprocess.run(args, capture_output=True, text=True, timeout=7200)
if completed.returncode:
detail = (completed.stderr or completed.stdout or "unknown error")[-4000:]
raise RuntimeError(detail)
@app.post("/v1/separate")
async def separate(file: UploadFile = File(...)) -> FileResponse:
suffix = Path(file.filename or "upload.wav").suffix.lower()
if suffix not in ALLOWED:
raise HTTPException(415, "Dieses Audioformat wird nicht unterstützt.")
if SEPARATION_LOCK.locked():
raise HTTPException(409, "Eine Trennung läuft bereits.")
job = Path(tempfile.mkdtemp(prefix="separate-", dir=JOB_DIR))
input_path = job / f"input{suffix}"
output_dir = job / "output"
output_dir.mkdir()
size = 0
try:
with input_path.open("wb") as handle:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_UPLOAD:
raise HTTPException(413, "Datei ist größer als 1 GiB.")
handle.write(chunk)
async with SEPARATION_LOCK:
await asyncio.to_thread(_run_separator, input_path, output_dir)
stems = sorted(output_dir.glob("*.flac"))
if len(stems) != 2:
raise RuntimeError(f"Erwartet wurden zwei FLAC-Dateien, gefunden: {len(stems)}")
archive = job / "athena-vocals-instrumental.zip"
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as bundle:
for stem in stems:
lower = stem.name.lower()
target = "vocals.flac" if "vocal" in lower else "instrumental.flac"
bundle.write(stem, target)
return FileResponse(
archive,
media_type="application/zip",
filename="athena-vocals-instrumental.zip",
background=BackgroundTask(_cleanup, job),
)
except HTTPException:
_cleanup(job)
raise
except subprocess.TimeoutExpired:
_cleanup(job)
raise HTTPException(504, "Die Trennung hat das Zeitlimit überschritten.")
except Exception as exc:
_cleanup(job)
raise HTTPException(500, f"Trennung fehlgeschlagen: {exc}")
@app.on_event("startup")
def prepare() -> None:
JOB_DIR.mkdir(parents=True, exist_ok=True)
MODEL_DIR.mkdir(parents=True, exist_ok=True)
for old in JOB_DIR.glob("separate-*"):
if old.is_dir() and time.time() - old.stat().st_mtime > 86400:
_cleanup(old)
@@ -0,0 +1,38 @@
services:
stem-separator:
build: .
image: mike-ai/bs-roformer-separator:0.47.0
container_name: mike-ai-stem-separator
labels:
com.mike-ai.stem-separator: "bs-roformer"
environment:
NVIDIA_VISIBLE_DEVICES: ${SEPARATOR_GPU_UUID:?set SEPARATOR_GPU_UUID to the RTX 5080 UUID}
MODEL_FILENAME: model_bs_roformer_ep_317_sdr_12.9755.ckpt
MODEL_DIR: /models
JOB_DIR: /data/jobs
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["${SEPARATOR_GPU_UUID:?set SEPARATOR_GPU_UUID to the RTX 5080 UUID}"]
capabilities: [gpu]
ports:
- "127.0.0.1:${SEPARATOR_PORT:-8007}:8080"
volumes:
- ${SEPARATOR_MODEL_DIR:-/data/models/audio-separator}:/models
- ${SEPARATOR_DATA_DIR:-/data/audio/separation}:/data
shm_size: "2gb"
restart: "no"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/health | grep -q '\"status\":\"ok\"'"]
interval: 15s
timeout: 5s
start_period: 600s
retries: 3
networks: [frontend]
networks:
frontend:
external: true
name: mike-ai_frontend
@@ -0,0 +1,4 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena · Stimmen trennen</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(760px,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}.drop{display:block;margin:28px 0;padding:44px 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}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}</style></head><body><main class="card"><div class="eyebrow">Athena Audio Lab</div><h1>Stimmen sauber trennen</h1><p>BS‑RoFormer Viperx 1297 zerlegt deinen Titel in zwei verlustfreie FLAC-Spuren: Gesang und Instrumental.</p><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>Vocals und Instrumental erzeugen</button><div class="status" id="status">Bereit.</div><div class="bar" id="bar"><div class="fill"></div></div><small>Die Verarbeitung läuft lokal auf Athena. Nichts wird in eine Cloud hochgeladen.</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;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()=>{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);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-vocals-instrumental.zip';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status.textContent='Fertig – ZIP mit vocals.flac und instrumental.flac wurde geladen.'}catch(e){status.textContent=`Fehler: ${e.message}`}finally{bar.classList.remove('run');start.disabled=false}};
</script></body></html>
@@ -30,6 +30,8 @@ TTS_LABEL_KEY = "com.mike-ai.tts-worker"
TTS_WORKER = os.environ.get("TTS_WORKER", "qwen3")
MUSIC_LABEL_KEY = "com.mike-ai.music-worker"
MUSIC_WORKER = os.environ.get("MUSIC_WORKER", "").strip()
SEPARATOR_LABEL_KEY = "com.mike-ai.stem-separator"
SEPARATOR_WORKER = os.environ.get("SEPARATOR_WORKER", "").strip()
LOCK = threading.Lock()
log = logging.getLogger("profile-controller")
@@ -109,11 +111,27 @@ def music_container() -> dict:
return matches[0]
def separator_container() -> dict:
if not SEPARATOR_WORKER:
raise RuntimeError("stem separator is not configured")
matches = [item for item in labelled_containers(SEPARATOR_LABEL_KEY)
if item.get("Labels", {}).get(SEPARATOR_LABEL_KEY) == SEPARATOR_WORKER]
if len(matches) != 1:
raise RuntimeError(
f"expected exactly one stem separator {SEPARATOR_WORKER!r}, found {len(matches)}")
return matches[0]
def stop_music_if_configured() -> None:
if MUSIC_WORKER:
stop_container(music_container(), timeout=30)
def stop_separator_if_configured() -> None:
if SEPARATOR_WORKER:
stop_container(separator_container(), timeout=30)
def stop_container(item: dict, timeout: int = 120) -> None:
if item.get("State") != "running":
return
@@ -152,6 +170,7 @@ def set_image_worker(running: bool, kind: str = IMAGE_WORKER) -> dict:
# Qwen3-TTS. The gateway retains Piper as a fallback meanwhile.
stop_container(tts_container(), timeout=30)
stop_music_if_configured()
stop_separator_if_configured()
for other in image_containers():
if other["Id"] != item["Id"]:
stop_container(other, timeout=20)
@@ -177,6 +196,7 @@ def set_music_worker(running: bool) -> dict:
for worker in image_containers():
stop_container(worker, timeout=20)
stop_container(tts_container(), timeout=30)
stop_separator_if_configured()
start_container(item)
else:
stop_container(item, timeout=30)
@@ -184,6 +204,24 @@ def set_music_worker(running: bool) -> dict:
"state": "running" if running else "stopped"}
def set_separator_worker(running: bool) -> dict:
"""Start vocal separation exclusively, or stop it before LLM restoration."""
with LOCK:
item = separator_container()
if running:
for profile_item in containers().values():
stop_container(profile_item)
for worker in image_containers():
stop_container(worker, timeout=20)
stop_container(tts_container(), timeout=30)
stop_music_if_configured()
start_container(item)
else:
stop_container(item, timeout=30)
return {"separator_worker": SEPARATOR_WORKER,
"state": "running" if running else "stopped"}
def active_profile(items: dict[str, dict] | None = None) -> str | None:
items = items or containers()
active = [name for name, item in items.items() if item.get("State") == "running"]
@@ -200,6 +238,7 @@ def activate(profile: str) -> dict:
for worker in image_containers():
stop_container(worker)
stop_music_if_configured()
stop_separator_if_configured()
start_container(tts_container())
items = containers()
missing = [name for name in ALLOWED if name not in items]
@@ -271,9 +310,18 @@ class Handler(BaseHTTPRequestHandler):
"unhealthy" if "(unhealthy)" in music_status else
"starting" if music.get("State") == "running" else
"stopped")
separator = separator_container() if SEPARATOR_WORKER else {}
separator_status = separator.get("Status", "")
separator_health = ("disabled" if not SEPARATOR_WORKER else
"healthy" if "(healthy)" in separator_status else
"unhealthy" if "(unhealthy)" in separator_status else
"starting" if separator.get("State") == "running" else
"stopped")
self.reply(200, {"active_profile": active_profile(items),
"music_worker": music.get("State", "disabled"),
"music_health": music_health,
"separator_worker": separator.get("State", "disabled"),
"separator_health": separator_health,
"profiles": {name: items.get(name, {}).get(
"State", "missing") for name in ALLOWED}})
except Exception as exc:
@@ -298,6 +346,13 @@ class Handler(BaseHTTPRequestHandler):
log.exception("music worker transition failed")
self.reply(503, {"error": str(exc)})
return
if self.path in {"/workers/separator/start", "/workers/separator/stop"}:
try:
self.reply(200, set_separator_worker(self.path.endswith("/start")))
except Exception as exc:
log.exception("stem separator transition failed")
self.reply(503, {"error": str(exc)})
return
worker_paths = {
"/workers/image/start": (IMAGE_WORKER, True),
"/workers/image/stop": (IMAGE_WORKER, False),
@@ -80,6 +80,7 @@ start_proxy 8091 piper:8085
start_proxy 8099 llama-dashboard:8099
start_proxy 7861 music-ui:3000
start_proxy 7862 music-worker:7860
start_proxy 8007 stem-separator:8080
start_proxy 8202 mcp-athena-operator:8000
start_proxy 9443 portainer:9443
+6 -4
View File
@@ -27,6 +27,7 @@ MUSIC_COMMUNITY_UI_URL = os.getenv(
MUSIC_ORIGINAL_UI_URL = os.getenv(
"MUSIC_ORIGINAL_UI_URL", "http://192.168.1.212:7862/"
)
SEPARATOR_UI_URL = os.getenv("SEPARATOR_UI_URL", "http://192.168.1.212:8007/")
HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc"))
HOST_DATA = os.getenv("HOST_DATA", "/host/data")
HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models"))
@@ -634,7 +635,7 @@ HTML = r'''<!doctype html>
</style></head><body><main>
<div class="top"><div><div class="eyebrow">Mike AI · Live Telemetry</div><h1>Athena llama.cpp Dashboard</h1></div><div class="live"><span class="dot" id="dot"></span><span id="updated">verbinde …</span></div></div>
<section class="grid">
<article class="card span12"><div class="mode-row"><div><div class="label">Athena Betriebsmodus</div><div class="value" id="operatingMode">–</div><div class="sub" id="modeStatus">Status wird geladen …</div></div><div class="mode-buttons"><button id="llmMode" onclick="setMode('llm')">LLM-Betrieb</button><button id="musicMode" onclick="setMode('music')">Musikstudio starten</button><span id="musicOpen" hidden><a class="stable" href="__MUSIC_ORIGINAL_UI_URL__" target="_blank" rel="noopener">Original UI · stabil</a><a class="experimental" href="__MUSIC_COMMUNITY_UI_URL__" target="_blank" rel="noopener">Community UI · experimentell</a></span></div></div></article>
<article class="card span12"><div class="mode-row"><div><div class="label">Athena Betriebsmodus</div><div class="value" id="operatingMode">–</div><div class="sub" id="modeStatus">Status wird geladen …</div></div><div class="mode-buttons"><button id="llmMode" onclick="setMode('llm')">LLM-Betrieb</button><button id="musicMode" onclick="setMode('music')">Musikstudio</button><button id="separationMode" onclick="setMode('separation')">Stimmen trennen</button><span id="musicOpen" hidden><a class="stable" href="__MUSIC_ORIGINAL_UI_URL__" target="_blank" rel="noopener">Original UI · stabil</a><a class="experimental" href="__MUSIC_COMMUNITY_UI_URL__" target="_blank" rel="noopener">Community UI · experimentell</a></span><span id="separatorOpen" hidden><a class="stable" href="__SEPARATOR_UI_URL__" target="_blank" rel="noopener">Separator öffnen</a></span></div></div></article>
<article class="card span3"><div class="label">Aktives Profil</div><div class="value" id="profile">–</div><div class="sub" id="profileSub">Router wird abgefragt</div></article>
<article class="card span3"><div class="label">Modell</div><div class="value" id="model">–</div><div class="sub" id="modelSub">–</div></article>
<article class="card span3"><div class="label">CPU</div><div class="value" id="cpu">–</div><div class="bar"><div class="fill" id="cpuBar"></div></div><div class="sub" id="load">–</div></article>
@@ -673,11 +674,12 @@ const $=id=>document.getElementById(id); const pct=n=>n==null?'–':`${n.toFixed
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0));return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${g.index}</div><div class="value">${g.name}</div></div><span class="badge">${g.pstate||'–'}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB</b><span>VRAM</span></div><div class="metric"><b>${g.temperature_c??'–'} °C</b><span>Temperatur</span></div><div class="metric"><b>${g.power_w??'–'} / ${g.power_limit_w??'–'} W</b><span>Leistung</span></div><div class="metric"><b>${g.graphics_clock_mhz??'–'} MHz</b><span>Grafiktakt</span></div><div class="metric"><b>${g.memory_clock_mhz??'–'} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${pct(g.memory_controller_percent)}</b><span>Memory Controller</span></div><div class="metric"><b>${pct(g.fan_percent)}</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${load.toFixed(1)} %</span></div><div class="bar"><div class="fill gpu-load" style="width:${load}%"></div></div><div class="bar-label"><span>VRAM-Belegung</span><span>${p.toFixed(1)} %</span></div><div class="bar"><div class="fill" style="width:${Math.min(100,p)}%"></div></div><div class="sub">${(g.memory_free_mib/1024).toFixed(1)} GiB VRAM frei</div></article>`}
const imagePhaseLabel=p=>({"stopping-qwen":"Qwen wird entladen","loading-image":"Bildmodell wird geladen","generating":"Bild wird generiert","unloading-image":"Bildmodell wird entladen","restoring-qwen":"Qwen wird wiederhergestellt"}[p]||p||'bereit');
let modeBusy=false;
async function setMode(mode){if(modeBusy)return;modeBusy=true;$('llmMode').disabled=$('musicMode').disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}}
async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready';$('operatingMode').textContent=md.active==='music'?'Musikstudio':'LLM-Betrieb';$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`Musik-Worker: ${md.music_worker||'–'}${md.return_profile?` · Rückkehr zu ${md.return_profile}`:''}`);$('modeStatus').classList.toggle('mode-error',!!md.last_error);$('llmMode').classList.toggle('active',md.active==='llm');$('musicMode').classList.toggle('active',md.active==='music');$('llmMode').disabled=$('musicMode').disabled=modeBusy||switchingMode||!md.enabled;$('musicOpen').hidden=md.active!=='music';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}</td><td>${p.name}</td><td>${p.pid}</td><td>${p.memory_mib??'–'} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`<div class="metric"><b>${v??'–'}</b><span>${k}</span></div>`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000);
async function setMode(mode){if(modeBusy)return;modeBusy=true;$('llmMode').disabled=$('musicMode').disabled=$('separationMode').disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}}
async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=md.active==='music'?'Musikstudio':md.active==='separation'?'Stimmtrennung':'LLM-Betrieb';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`Musik: ${md.music_worker||'–'} · Separator: ${md.separator_worker||'–'}${md.return_profile?` · Rückkehr zu ${md.return_profile}`:''}`);$('modeStatus').classList.toggle('mode-error',!!md.last_error);$('llmMode').classList.toggle('active',md.active==='llm');$('musicMode').classList.toggle('active',md.active==='music');$('separationMode').classList.toggle('active',md.active==='separation');$('llmMode').disabled=$('musicMode').disabled=$('separationMode').disabled=modeBusy||switchingMode||!md.enabled;$('musicOpen').hidden=md.active!=='music';$('separatorOpen').hidden=md.active!=='separation';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}</td><td>${p.name}</td><td>${p.pid}</td><td>${p.memory_mib??'–'} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`<div class="metric"><b>${v??'–'}</b><span>${k}</span></div>`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000);
</script><script src="/full.js"></script><script src="/history.js"></script></body></html>'''.replace(
"__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL
).replace("__MUSIC_COMMUNITY_UI_URL__", MUSIC_COMMUNITY_UI_URL)
).replace("__MUSIC_COMMUNITY_UI_URL__", MUSIC_COMMUNITY_UI_URL
).replace("__SEPARATOR_UI_URL__", SEPARATOR_UI_URL)
FULL_JS = r'''
+89 -35
View File
@@ -109,6 +109,7 @@ PROFILE_CONTROL_TOKEN_FILE = os.environ.get(
ENABLE_MUSIC_MODE = os.environ.get(
"ENABLE_MUSIC_MODE", "false").lower() in {"1", "true", "yes"}
MUSIC_START_TIMEOUT = float(os.environ.get("MUSIC_START_TIMEOUT", "600"))
SEPARATOR_START_TIMEOUT = float(os.environ.get("SEPARATOR_START_TIMEOUT", "600"))
# Optional worker APIs. The clean Docker baseline deliberately ships only
# text/multimodal chat; absent workers must fail explicitly instead of trying
@@ -339,6 +340,27 @@ def _music_worker_health() -> str:
return "unknown"
def _separator_worker_state() -> str:
if not PROFILE_CONTROL_URL:
return "unsupported"
try:
return str(_profile_controller_request("GET", "/status").get(
"separator_worker", "missing"))
except Exception as exc:
log.warning("Stem-Separator-Status nicht verfügbar: %s", exc)
return "unknown"
def _separator_worker_health() -> str:
if not PROFILE_CONTROL_URL:
return "unsupported"
try:
return str(_profile_controller_request("GET", "/status").get(
"separator_health", "unknown"))
except Exception:
return "unknown"
def _wait_music_ready() -> None:
deadline = time.monotonic() + MUSIC_START_TIMEOUT
while time.monotonic() < deadline:
@@ -353,38 +375,57 @@ def _wait_music_ready() -> None:
f"ACE-Step nach {MUSIC_START_TIMEOUT:.0f} s nicht bereit")
def _wait_separator_ready() -> None:
deadline = time.monotonic() + SEPARATOR_START_TIMEOUT
while time.monotonic() < deadline:
status = _profile_controller_request("GET", "/status")
if (status.get("separator_worker") == "running"
and status.get("separator_health") == "healthy"):
return
if status.get("separator_health") == "unhealthy":
raise RuntimeError("BS-RoFormer-Container ist unhealthy")
time.sleep(POLL_INTERVAL)
raise RuntimeError(
f"BS-RoFormer nach {SEPARATOR_START_TIMEOUT:.0f} s nicht bereit")
def set_operating_mode(mode: str) -> dict:
"""Atomarer Wechsel zwischen llama.cpp/TTS und ACE-Step Studio."""
"""Atomarer Wechsel zwischen LLM, ACE-Step und Stem-Separation."""
if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL:
raise RuntimeError("Musikmodus ist nicht konfiguriert")
if mode not in {"llm", "music"}:
raise ValueError("Modus muss 'llm' oder 'music' sein")
if mode not in {"llm", "music", "separation"}:
raise ValueError("Modus muss 'llm', 'music' oder 'separation' sein")
with STATE.lock:
STATE.mode_error = None
if mode == "music":
if STATE.mode == "music" and _music_worker_state() == "running":
return {"status": "ok", "mode": "music", "changed": False}
if mode in {"music", "separation"}:
worker_state = (_music_worker_state() if mode == "music"
else _separator_worker_state())
if STATE.mode == mode and worker_state == "running":
return {"status": "ok", "mode": mode, "changed": False}
profile = current_profile()
saved = RUNTIME.load().get("last_profile")
previous = RUNTIME.load()
saved = previous.get("return_profile") or previous.get("last_profile")
return_profile = profile if profile in PROFILES else saved
if return_profile not in PROFILES:
return_profile = next(iter(PROFILES))
STATE.mode_phase = "starting-music"
STATE.mode_phase = f"starting-{mode}"
_set_qwen_unavailable(True)
try:
# Persist intent before stopping anything so a router restart
# during ACE-Step loading can resume the same transition.
RUNTIME.save(mode="music", return_profile=return_profile,
RUNTIME.save(mode=mode, return_profile=return_profile,
last_profile=return_profile,
phase="starting-music")
phase=f"starting-{mode}")
_wait_chats_drained()
_profile_controller_request("POST", "/workers/music/start")
_wait_music_ready()
STATE.mode = "music"
path = ("/workers/music/start" if mode == "music"
else "/workers/separator/start")
_profile_controller_request("POST", path)
_wait_music_ready() if mode == "music" else _wait_separator_ready()
STATE.mode = mode
STATE.mode_phase = "ready"
RUNTIME.save(mode="music", return_profile=return_profile,
last_profile=return_profile, phase="music")
return {"status": "ok", "mode": "music", "changed": True,
RUNTIME.save(mode=mode, return_profile=return_profile,
last_profile=return_profile, phase=mode)
return {"status": "ok", "mode": mode, "changed": True,
"return_profile": return_profile}
except Exception as exc:
STATE.mode_error = str(exc)
@@ -399,6 +440,7 @@ def set_operating_mode(mode: str) -> dict:
_set_qwen_unavailable(True)
try:
_profile_controller_request("POST", "/workers/music/stop")
_profile_controller_request("POST", "/workers/separator/stop")
_restore_qwen(profile)
STATE.mode = "llm"
STATE.mode_phase = "ready"
@@ -417,14 +459,14 @@ def schedule_operating_mode(mode: str) -> tuple[bool, str]:
"""Start a transition in the background so chat/UI acknowledgement is instant."""
if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL:
raise RuntimeError("Musikmodus ist nicht konfiguriert")
if mode not in {"llm", "music"}:
raise ValueError("Modus muss 'llm' oder 'music' sein")
if mode not in {"llm", "music", "separation"}:
raise ValueError("Modus muss 'llm', 'music' oder 'separation' sein")
with STATE.lock:
if STATE.mode_phase not in {"ready", "error"}:
return False, STATE.mode_phase
if STATE.mode == mode and STATE.mode_phase == "ready":
return False, "ready"
STATE.mode_phase = "starting-music" if mode == "music" else "restoring-llm"
STATE.mode_phase = f"starting-{mode}" if mode != "llm" else "restoring-llm"
def transition() -> None:
try:
@@ -452,7 +494,9 @@ def _control_command(data: dict, path: str) -> str | None:
if not isinstance(text, str):
return None
command = text.strip().casefold()
return command if command in {"/athena music", "/athena llm", "/athena status"} else None
return command if command in {"/athena music", "/athena stems",
"/athena separation", "/athena llm",
"/athena status"} else None
# ---------------------------------------------------------------------------
@@ -1925,6 +1969,8 @@ class Handler(BaseHTTPRequestHandler):
"phase": STATE.mode_phase,
"music_worker": _music_worker_state(),
"music_health": _music_worker_health(),
"separator_worker": _separator_worker_state(),
"separator_health": _separator_worker_health(),
"return_profile": state.get("return_profile"),
"last_error": STATE.mode_error,
"enabled": ENABLE_MUSIC_MODE,
@@ -1934,8 +1980,8 @@ class Handler(BaseHTTPRequestHandler):
try:
data = json.loads(self._read_body() or b"{}")
mode = data.get("mode") if isinstance(data, dict) else None
if mode not in {"llm", "music"}:
raise ValueError("Feld 'mode' muss 'llm' oder 'music' sein")
if mode not in {"llm", "music", "separation"}:
raise ValueError("Feld 'mode' muss 'llm', 'music' oder 'separation' sein")
started, phase = schedule_operating_mode(mode)
self._send_json(202 if started else 200, {
"status": "accepted" if started else "ok",
@@ -2597,16 +2643,20 @@ class Handler(BaseHTTPRequestHandler):
profile = current_profile()
text = (f"Athena läuft im {mode['active'].upper()}-Modus. "
f"Phase: {mode['phase']}. Musik-Worker: "
f"{mode['music_worker']}. LLM-Profil: {profile or 'entladen'}.")
f"{mode['music_worker']}. Stem-Separator: "
f"{mode['separator_worker']}. LLM-Profil: {profile or 'entladen'}.")
else:
target = "music" if command == "/athena music" else "llm"
target = ("music" if command == "/athena music" else
"separation" if command in {"/athena stems", "/athena separation"}
else "llm")
try:
started, phase = schedule_operating_mode(target)
if started:
text = ("Musikstudio wird gestartet. Das LLM und TTS werden "
"entladen; der Fortschritt ist im Athena-Dashboard sichtbar."
text = ("Musikstudio wird gestartet. LLM und TTS werden entladen."
if target == "music" else
"Musikstudio wird beendet und das vorherige LLM-Profil wird wiederhergestellt.")
"Stimmtrennung wird gestartet. LLM und TTS werden entladen."
if target == "separation" else
"Spezialmodus wird beendet und das vorherige LLM-Profil wiederhergestellt.")
else:
text = (f"Athena ist bereits im {target.upper()}-Modus "
f"oder wechselt gerade ({phase}).")
@@ -2897,20 +2947,24 @@ def _startup_reconcile() -> None:
if removed:
log.info("Startup-Retention: %d alte Bilder entfernt", len(removed))
if ENABLE_MUSIC_MODE and previous.get("mode") == "music":
STATE.mode = "music"
STATE.mode_phase = "starting-music"
special_mode = previous.get("mode")
if ENABLE_MUSIC_MODE and special_mode in {"music", "separation"}:
STATE.mode = special_mode
STATE.mode_phase = f"starting-{special_mode}"
_set_qwen_unavailable(True)
try:
_profile_controller_request("POST", "/workers/music/start")
_wait_music_ready()
path = ("/workers/music/start" if special_mode == "music"
else "/workers/separator/start")
_profile_controller_request("POST", path)
_wait_music_ready() if special_mode == "music" else _wait_separator_ready()
STATE.mode_phase = "ready"
RUNTIME.save(mode="music", phase="music")
log.info("Recovery: Musikmodus wiederhergestellt")
RUNTIME.save(mode=special_mode, phase=special_mode)
log.info("Recovery: Spezialmodus %s wiederhergestellt", special_mode)
except Exception as exc:
STATE.mode_error = str(exc)
STATE.mode_phase = "error"
log.error("Recovery: Musikmodus konnte nicht gestartet werden: %s", exc)
log.error("Recovery: Spezialmodus %s konnte nicht gestartet werden: %s",
special_mode, exc)
return
profile = current_profile()