From 0069b61dbb4ef5cdba5c812470d5d67250d3c7d0 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:23:29 +0200 Subject: [PATCH] Add BS-RoFormer vocal separation mode --- README.md | 7 +- compose.yaml | 2 + dev/test_profile_controller.py | 33 +++++ docs/OPERATING_MODES.md | 29 ++-- docs/SPECIALIZED_MODEL_ROADMAP.md | 2 +- docs/TESTED_MODELS.md | 6 + .../bs-roformer-vocal-separation/Dockerfile | 22 ++++ .../bs-roformer-vocal-separation/README.md | 16 +++ .../bs-roformer-vocal-separation/app.py | 124 ++++++++++++++++++ .../bs-roformer-vocal-separation/compose.yaml | 38 ++++++ .../bs-roformer-vocal-separation/index.html | 4 + .../profile-controller/profile_controller.py | 55 ++++++++ .../docker/wireguard-gateway/entrypoint.sh | 1 + platform/llama-dashboard/app.py | 10 +- router/ai_profile_router.py | 124 +++++++++++++----- 15 files changed, 421 insertions(+), 52 deletions(-) create mode 100644 experiments/bs-roformer-vocal-separation/Dockerfile create mode 100644 experiments/bs-roformer-vocal-separation/README.md create mode 100644 experiments/bs-roformer-vocal-separation/app.py create mode 100644 experiments/bs-roformer-vocal-separation/compose.yaml create mode 100644 experiments/bs-roformer-vocal-separation/index.html diff --git a/README.md b/README.md index 0792a40..245a40e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compose.yaml b/compose.yaml index 3eabdf0..c22ce74 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/dev/test_profile_controller.py b/dev/test_profile_controller.py index c84106d..95d0f06 100644 --- a/dev/test_profile_controller.py +++ b/dev/test_profile_controller.py @@ -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") diff --git a/docs/OPERATING_MODES.md b/docs/OPERATING_MODES.md index 7de8904..5e0fc5a 100644 --- a/docs/OPERATING_MODES.md +++ b/docs/OPERATING_MODES.md @@ -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. diff --git a/docs/SPECIALIZED_MODEL_ROADMAP.md b/docs/SPECIALIZED_MODEL_ROADMAP.md index 82d0c8e..ea8a46b 100644 --- a/docs/SPECIALIZED_MODEL_ROADMAP.md +++ b/docs/SPECIALIZED_MODEL_ROADMAP.md @@ -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 | diff --git a/docs/TESTED_MODELS.md b/docs/TESTED_MODELS.md index 4847c1d..1d1ef28 100644 --- a/docs/TESTED_MODELS.md +++ b/docs/TESTED_MODELS.md @@ -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. diff --git a/experiments/bs-roformer-vocal-separation/Dockerfile b/experiments/bs-roformer-vocal-separation/Dockerfile new file mode 100644 index 0000000..442aa7b --- /dev/null +++ b/experiments/bs-roformer-vocal-separation/Dockerfile @@ -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"] diff --git a/experiments/bs-roformer-vocal-separation/README.md b/experiments/bs-roformer-vocal-separation/README.md new file mode 100644 index 0000000..6dc0dca --- /dev/null +++ b/experiments/bs-roformer-vocal-separation/README.md @@ -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. diff --git a/experiments/bs-roformer-vocal-separation/app.py b/experiments/bs-roformer-vocal-separation/app.py new file mode 100644 index 0000000..c0a7579 --- /dev/null +++ b/experiments/bs-roformer-vocal-separation/app.py @@ -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) diff --git a/experiments/bs-roformer-vocal-separation/compose.yaml b/experiments/bs-roformer-vocal-separation/compose.yaml new file mode 100644 index 0000000..4196737 --- /dev/null +++ b/experiments/bs-roformer-vocal-separation/compose.yaml @@ -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 diff --git a/experiments/bs-roformer-vocal-separation/index.html b/experiments/bs-roformer-vocal-separation/index.html new file mode 100644 index 0000000..ef43742 --- /dev/null +++ b/experiments/bs-roformer-vocal-separation/index.html @@ -0,0 +1,4 @@ +Athena · Stimmen trennen
Athena Audio Lab

Stimmen sauber trennen

BS‑RoFormer Viperx 1297 zerlegt deinen Titel in zwei verlustfreie FLAC-Spuren: Gesang und Instrumental.

Bereit.
Die Verarbeitung läuft lokal auf Athena. Nichts wird in eine Cloud hochgeladen.
diff --git a/platform/docker/profile-controller/profile_controller.py b/platform/docker/profile-controller/profile_controller.py index 900b080..341726a 100644 --- a/platform/docker/profile-controller/profile_controller.py +++ b/platform/docker/profile-controller/profile_controller.py @@ -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), diff --git a/platform/docker/wireguard-gateway/entrypoint.sh b/platform/docker/wireguard-gateway/entrypoint.sh index f74c879..f0b4b3d 100644 --- a/platform/docker/wireguard-gateway/entrypoint.sh +++ b/platform/docker/wireguard-gateway/entrypoint.sh @@ -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 diff --git a/platform/llama-dashboard/app.py b/platform/llama-dashboard/app.py index 2f7a162..f1b9d32 100644 --- a/platform/llama-dashboard/app.py +++ b/platform/llama-dashboard/app.py @@ -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'''
Mike AI · Live Telemetry

Athena llama.cpp Dashboard

verbinde …
- +
Aktives Profil
–
Router wird abgefragt
Modell
–
–
CPU
–
–
@@ -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 `
GPU ${g.index}
${g.name}
${g.pstate||'–'}
${pct(g.gpu_percent)}GPU-Kern
${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiBVRAM
${g.temperature_c??'–'} °CTemperatur
${g.power_w??'–'} / ${g.power_limit_w??'–'} WLeistung
${g.graphics_clock_mhz??'–'} MHzGrafiktakt
${g.memory_clock_mhz??'–'} MHzSpeichertakt
${pct(g.memory_controller_percent)}Memory Controller
${pct(g.fan_percent)}Lüfter
GPU-Auslastung${load.toFixed(1)} %
VRAM-Belegung${p.toFixed(1)} %
${(g.memory_free_mib/1024).toFixed(1)} GiB VRAM frei
`} 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('')||'
Keine GPU-Daten verfügbar
';$('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=>`${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}${p.name}${p.pid}${p.memory_mib??'–'} MiB`).join('')||'Keine Compute-Prozesse gemeldet';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])=>`
${v??'–'}${k}
`).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('')||'
Keine GPU-Daten verfügbar
';$('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=>`${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}${p.name}${p.pid}${p.memory_mib??'–'} MiB`).join('')||'Keine Compute-Prozesse gemeldet';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])=>`
${v??'–'}${k}
`).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); '''.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''' diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index 13fcc24..fd365c8 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -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()