Add BS-RoFormer vocal separation mode

This commit is contained in:
Mikei386 committed 2026-09-08 19:23:29 +02:00
1 parent a4e894fe70
commit 0069b61dbb
15 files changed
+421 -52

No files matched your search

+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 - 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 - Whisper.cpp `large-v3-turbo` auf der CPU für lokale deutsche Spracherkennung
- Live-Dashboard mit 21 Tagen Detailhistorie auf Port 8099 - Live-Dashboard mit 21 Tagen Detailhistorie auf Port 8099
- Dashboard-Umschaltung zwischen LLM-Betrieb und ACE-Step-Musikstudio mit - Dashboard-Umschaltung zwischen LLM-Betrieb, ACE-Step-Musikstudio und
persistenter `fspecii/ace-step-ui`-Bibliothek BS-RoFormer-Stimmtrennung
- Portainer CE als optionale Container-Ansicht auf Port 9443 - Portainer CE als optionale Container-Ansicht auf Port 9443
- WireGuard-Gateway, Datenbackup und Athena-Operator - WireGuard-Gateway, Datenbackup und Athena-Operator
- keine produktive Hermes-, OpenWebUI- oder portable Fach-MCP-Instanz - 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` - Athena-Dashboard: `http://192.168.1.212:8099`
- Musikstudio, Original UI (stabil): `http://192.168.1.212:7862` - Musikstudio, Original UI (stabil): `http://192.168.1.212:7862`
- Musikstudio, Community UI (experimentell): `http://192.168.1.212:7861` - 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 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). [docs/OPERATING_MODES.md](docs/OPERATING_MODES.md).
Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über
+2
View File
@@ -570,6 +570,7 @@ services:
RESTORE_WORKER: restore RESTORE_WORKER: restore
TTS_WORKER: qwen3 TTS_WORKER: qwen3
MUSIC_WORKER: acestep MUSIC_WORKER: acestep
SEPARATOR_WORKER: bs-roformer
networks: [control] networks: [control]
security_opt: ["no-new-privileges:true"] security_opt: ["no-new-privileges:true"]
healthcheck: healthcheck:
@@ -858,6 +859,7 @@ services:
ROUTER_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}" 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_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/}" 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_PROC: /host/proc
HOST_DATA: /host/data HOST_DATA: /host/data
HOST_MODELS: /host/models HOST_MODELS: /host/models
+33
View File
@@ -41,7 +41,40 @@ def music_item(state="exited"):
"Labels": {controller.MUSIC_LABEL_KEY: "acestep"}} "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): 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): def test_music_start_exclusively_stops_gpu_workers(self):
profiles = {name: item(name) for name in controller.ALLOWED} profiles = {name: item(name) for name in controller.ALLOWED}
profiles["ultra"] = item("ultra", "running") profiles["ultra"] = item("ultra", "running")
+20 -9
View File
@@ -1,18 +1,20 @@
# Athena-Betriebsmodi # 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. - `llm`: ein llama.cpp-Profil und Qwen3-TTS laufen; Spezialdienste sind gestoppt.
- `music`: ACE-Step 1.5 XL-SFT läuft; alle LLM-, Bild- und TTS-Worker 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 Die Zustandsmaschine lebt im Athena-Router. Das Dashboard und Chat-Clients wie
Hermes sind nur Bedienoberflächen derselben API. Der zuletzt aktive LLM-Modus 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 ## Bedienung
Im Athena-Dashboard stehen die Schaltflächen **LLM-Betrieb** und Im Athena-Dashboard stehen **LLM-Betrieb**, **Musikstudio** und
**Musikstudio starten** bereit. Im Musikmodus werden zwei Oberflächen angeboten: **Stimmen trennen** bereit. Im Musikmodus werden zwei Oberflächen angeboten:
- **Original UI · stabil** öffnet die zum laufenden ACE-Step-Image gehörende - **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 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 `a1fdf91829ec6f7b98844f80e323529cd155dbf2` fixiert und greift intern über das
Docker-Netz `mike-ai-music` auf `http://music-worker:7860` zu. 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 Hermes benötigt dafür kein Plugin. Exakt eingegebene Steuerbefehle werden vom
Router lokal beantwortet, auch wenn gerade kein LLM geladen ist: Router lokal beantwortet, auch wenn gerade kein LLM geladen ist:
```text ```text
/athena music /athena music
/athena stems
/athena llm /athena llm
/athena status /athena status
``` ```
@@ -46,17 +55,19 @@ Die HTTP-Schnittstelle verwendet authentifizierte Requests:
```text ```text
GET /mode GET /mode
POST /mode {"mode":"music"} POST /mode {"mode":"music"}
POST /mode {"mode":"separation"}
POST /mode {"mode":"llm"} POST /mode {"mode":"llm"}
``` ```
Der Wechsel läuft asynchron. Fortschritt und Fehler stehen unter `mode` in Der Wechsel läuft asynchron. Fortschritt und Fehler stehen unter `mode` in
`GET /status`. Der Profile-Controller akzeptiert ausschließlich den mit `GET /status`. Der Profile-Controller akzeptiert ausschließlich den mit
`com.mike-ai.music-worker=acestep` markierten Container; freie Container- oder `com.mike-ai.music-worker=acestep` beziehungsweise
Docker-Befehle werden nicht entgegengenommen. `com.mike-ai.stem-separator=bs-roformer` markierten Container; freie
Container- oder Docker-Befehle werden nicht entgegengenommen.
## Wiederanlauf ## Wiederanlauf
Der Router speichert `mode`, `last_profile` und `return_profile` atomar. War 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 Wechsel zurück wird das gespeicherte LLM-Profil semantisch auf Alias und
Kontextfenster geprüft, bevor Chat-Anfragen wieder freigegeben werden. 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 | | 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** | | 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 | | 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 | | 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 | | 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) | | 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 ## Ablauf für zukünftige Kandidaten
1. Exakten Hugging-Face-/Ollama-Namen und Dateinamen in diesem Dokument suchen. 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") TTS_WORKER = os.environ.get("TTS_WORKER", "qwen3")
MUSIC_LABEL_KEY = "com.mike-ai.music-worker" MUSIC_LABEL_KEY = "com.mike-ai.music-worker"
MUSIC_WORKER = os.environ.get("MUSIC_WORKER", "").strip() 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() LOCK = threading.Lock()
log = logging.getLogger("profile-controller") log = logging.getLogger("profile-controller")
@@ -109,11 +111,27 @@ def music_container() -> dict:
return matches[0] 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: def stop_music_if_configured() -> None:
if MUSIC_WORKER: if MUSIC_WORKER:
stop_container(music_container(), timeout=30) 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: def stop_container(item: dict, timeout: int = 120) -> None:
if item.get("State") != "running": if item.get("State") != "running":
return 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. # Qwen3-TTS. The gateway retains Piper as a fallback meanwhile.
stop_container(tts_container(), timeout=30) stop_container(tts_container(), timeout=30)
stop_music_if_configured() stop_music_if_configured()
stop_separator_if_configured()
for other in image_containers(): for other in image_containers():
if other["Id"] != item["Id"]: if other["Id"] != item["Id"]:
stop_container(other, timeout=20) stop_container(other, timeout=20)
@@ -177,6 +196,7 @@ def set_music_worker(running: bool) -> dict:
for worker in image_containers(): for worker in image_containers():
stop_container(worker, timeout=20) stop_container(worker, timeout=20)
stop_container(tts_container(), timeout=30) stop_container(tts_container(), timeout=30)
stop_separator_if_configured()
start_container(item) start_container(item)
else: else:
stop_container(item, timeout=30) stop_container(item, timeout=30)
@@ -184,6 +204,24 @@ def set_music_worker(running: bool) -> dict:
"state": "running" if running else "stopped"} "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: def active_profile(items: dict[str, dict] | None = None) -> str | None:
items = items or containers() items = items or containers()
active = [name for name, item in items.items() if item.get("State") == "running"] 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(): for worker in image_containers():
stop_container(worker) stop_container(worker)
stop_music_if_configured() stop_music_if_configured()
stop_separator_if_configured()
start_container(tts_container()) start_container(tts_container())
items = containers() items = containers()
missing = [name for name in ALLOWED if name not in items] 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 "unhealthy" if "(unhealthy)" in music_status else
"starting" if music.get("State") == "running" else "starting" if music.get("State") == "running" else
"stopped") "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), self.reply(200, {"active_profile": active_profile(items),
"music_worker": music.get("State", "disabled"), "music_worker": music.get("State", "disabled"),
"music_health": music_health, "music_health": music_health,
"separator_worker": separator.get("State", "disabled"),
"separator_health": separator_health,
"profiles": {name: items.get(name, {}).get( "profiles": {name: items.get(name, {}).get(
"State", "missing") for name in ALLOWED}}) "State", "missing") for name in ALLOWED}})
except Exception as exc: except Exception as exc:
@@ -298,6 +346,13 @@ class Handler(BaseHTTPRequestHandler):
log.exception("music worker transition failed") log.exception("music worker transition failed")
self.reply(503, {"error": str(exc)}) self.reply(503, {"error": str(exc)})
return 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 = { worker_paths = {
"/workers/image/start": (IMAGE_WORKER, True), "/workers/image/start": (IMAGE_WORKER, True),
"/workers/image/stop": (IMAGE_WORKER, False), "/workers/image/stop": (IMAGE_WORKER, False),
@@ -80,6 +80,7 @@ start_proxy 8091 piper:8085
start_proxy 8099 llama-dashboard:8099 start_proxy 8099 llama-dashboard:8099
start_proxy 7861 music-ui:3000 start_proxy 7861 music-ui:3000
start_proxy 7862 music-worker:7860 start_proxy 7862 music-worker:7860
start_proxy 8007 stem-separator:8080
start_proxy 8202 mcp-athena-operator:8000 start_proxy 8202 mcp-athena-operator:8000
start_proxy 9443 portainer:9443 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 = os.getenv(
"MUSIC_ORIGINAL_UI_URL", "http://192.168.1.212:7862/" "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_PROC = Path(os.getenv("HOST_PROC", "/host/proc"))
HOST_DATA = os.getenv("HOST_DATA", "/host/data") HOST_DATA = os.getenv("HOST_DATA", "/host/data")
HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models")) HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models"))
@@ -634,7 +635,7 @@ HTML = r'''<!doctype html>
</style></head><body><main> </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> <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"> <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">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">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> <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>`} 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'); 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; 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 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';$('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 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( </script><script src="/full.js"></script><script src="/history.js"></script></body></html>'''.replace(
"__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL "__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''' 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 = os.environ.get(
"ENABLE_MUSIC_MODE", "false").lower() in {"1", "true", "yes"} "ENABLE_MUSIC_MODE", "false").lower() in {"1", "true", "yes"}
MUSIC_START_TIMEOUT = float(os.environ.get("MUSIC_START_TIMEOUT", "600")) 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 # Optional worker APIs. The clean Docker baseline deliberately ships only
# text/multimodal chat; absent workers must fail explicitly instead of trying # text/multimodal chat; absent workers must fail explicitly instead of trying
@@ -339,6 +340,27 @@ def _music_worker_health() -> str:
return "unknown" 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: def _wait_music_ready() -> None:
deadline = time.monotonic() + MUSIC_START_TIMEOUT deadline = time.monotonic() + MUSIC_START_TIMEOUT
while time.monotonic() < deadline: while time.monotonic() < deadline:
@@ -353,38 +375,57 @@ def _wait_music_ready() -> None:
f"ACE-Step nach {MUSIC_START_TIMEOUT:.0f} s nicht bereit") 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: 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: if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL:
raise RuntimeError("Musikmodus ist nicht konfiguriert") raise RuntimeError("Musikmodus ist nicht konfiguriert")
if mode not in {"llm", "music"}: if mode not in {"llm", "music", "separation"}:
raise ValueError("Modus muss 'llm' oder 'music' sein") raise ValueError("Modus muss 'llm', 'music' oder 'separation' sein")
with STATE.lock: with STATE.lock:
STATE.mode_error = None STATE.mode_error = None
if mode == "music": if mode in {"music", "separation"}:
if STATE.mode == "music" and _music_worker_state() == "running": worker_state = (_music_worker_state() if mode == "music"
return {"status": "ok", "mode": "music", "changed": False} else _separator_worker_state())
if STATE.mode == mode and worker_state == "running":
return {"status": "ok", "mode": mode, "changed": False}
profile = current_profile() 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 return_profile = profile if profile in PROFILES else saved
if return_profile not in PROFILES: if return_profile not in PROFILES:
return_profile = next(iter(PROFILES)) return_profile = next(iter(PROFILES))
STATE.mode_phase = "starting-music" STATE.mode_phase = f"starting-{mode}"
_set_qwen_unavailable(True) _set_qwen_unavailable(True)
try: try:
# Persist intent before stopping anything so a router restart # Persist intent before stopping anything so a router restart
# during ACE-Step loading can resume the same transition. # 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, last_profile=return_profile,
phase="starting-music") phase=f"starting-{mode}")
_wait_chats_drained() _wait_chats_drained()
_profile_controller_request("POST", "/workers/music/start") path = ("/workers/music/start" if mode == "music"
_wait_music_ready() else "/workers/separator/start")
STATE.mode = "music" _profile_controller_request("POST", path)
_wait_music_ready() if mode == "music" else _wait_separator_ready()
STATE.mode = mode
STATE.mode_phase = "ready" STATE.mode_phase = "ready"
RUNTIME.save(mode="music", return_profile=return_profile, RUNTIME.save(mode=mode, return_profile=return_profile,
last_profile=return_profile, phase="music") last_profile=return_profile, phase=mode)
return {"status": "ok", "mode": "music", "changed": True, return {"status": "ok", "mode": mode, "changed": True,
"return_profile": return_profile} "return_profile": return_profile}
except Exception as exc: except Exception as exc:
STATE.mode_error = str(exc) STATE.mode_error = str(exc)
@@ -399,6 +440,7 @@ def set_operating_mode(mode: str) -> dict:
_set_qwen_unavailable(True) _set_qwen_unavailable(True)
try: try:
_profile_controller_request("POST", "/workers/music/stop") _profile_controller_request("POST", "/workers/music/stop")
_profile_controller_request("POST", "/workers/separator/stop")
_restore_qwen(profile) _restore_qwen(profile)
STATE.mode = "llm" STATE.mode = "llm"
STATE.mode_phase = "ready" 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.""" """Start a transition in the background so chat/UI acknowledgement is instant."""
if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL: if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL:
raise RuntimeError("Musikmodus ist nicht konfiguriert") raise RuntimeError("Musikmodus ist nicht konfiguriert")
if mode not in {"llm", "music"}: if mode not in {"llm", "music", "separation"}:
raise ValueError("Modus muss 'llm' oder 'music' sein") raise ValueError("Modus muss 'llm', 'music' oder 'separation' sein")
with STATE.lock: with STATE.lock:
if STATE.mode_phase not in {"ready", "error"}: if STATE.mode_phase not in {"ready", "error"}:
return False, STATE.mode_phase return False, STATE.mode_phase
if STATE.mode == mode and STATE.mode_phase == "ready": if STATE.mode == mode and STATE.mode_phase == "ready":
return False, "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: def transition() -> None:
try: try:
@@ -452,7 +494,9 @@ def _control_command(data: dict, path: str) -> str | None:
if not isinstance(text, str): if not isinstance(text, str):
return None return None
command = text.strip().casefold() 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, "phase": STATE.mode_phase,
"music_worker": _music_worker_state(), "music_worker": _music_worker_state(),
"music_health": _music_worker_health(), "music_health": _music_worker_health(),
"separator_worker": _separator_worker_state(),
"separator_health": _separator_worker_health(),
"return_profile": state.get("return_profile"), "return_profile": state.get("return_profile"),
"last_error": STATE.mode_error, "last_error": STATE.mode_error,
"enabled": ENABLE_MUSIC_MODE, "enabled": ENABLE_MUSIC_MODE,
@@ -1934,8 +1980,8 @@ class Handler(BaseHTTPRequestHandler):
try: try:
data = json.loads(self._read_body() or b"{}") data = json.loads(self._read_body() or b"{}")
mode = data.get("mode") if isinstance(data, dict) else None mode = data.get("mode") if isinstance(data, dict) else None
if mode not in {"llm", "music"}: if mode not in {"llm", "music", "separation"}:
raise ValueError("Feld 'mode' muss 'llm' oder 'music' sein") raise ValueError("Feld 'mode' muss 'llm', 'music' oder 'separation' sein")
started, phase = schedule_operating_mode(mode) started, phase = schedule_operating_mode(mode)
self._send_json(202 if started else 200, { self._send_json(202 if started else 200, {
"status": "accepted" if started else "ok", "status": "accepted" if started else "ok",
@@ -2597,16 +2643,20 @@ class Handler(BaseHTTPRequestHandler):
profile = current_profile() profile = current_profile()
text = (f"Athena läuft im {mode['active'].upper()}-Modus. " text = (f"Athena läuft im {mode['active'].upper()}-Modus. "
f"Phase: {mode['phase']}. Musik-Worker: " 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: 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: try:
started, phase = schedule_operating_mode(target) started, phase = schedule_operating_mode(target)
if started: if started:
text = ("Musikstudio wird gestartet. Das LLM und TTS werden " text = ("Musikstudio wird gestartet. LLM und TTS werden entladen."
"entladen; der Fortschritt ist im Athena-Dashboard sichtbar."
if target == "music" else 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: else:
text = (f"Athena ist bereits im {target.upper()}-Modus " text = (f"Athena ist bereits im {target.upper()}-Modus "
f"oder wechselt gerade ({phase}).") f"oder wechselt gerade ({phase}).")
@@ -2897,20 +2947,24 @@ def _startup_reconcile() -> None:
if removed: if removed:
log.info("Startup-Retention: %d alte Bilder entfernt", len(removed)) log.info("Startup-Retention: %d alte Bilder entfernt", len(removed))
if ENABLE_MUSIC_MODE and previous.get("mode") == "music": special_mode = previous.get("mode")
STATE.mode = "music" if ENABLE_MUSIC_MODE and special_mode in {"music", "separation"}:
STATE.mode_phase = "starting-music" STATE.mode = special_mode
STATE.mode_phase = f"starting-{special_mode}"
_set_qwen_unavailable(True) _set_qwen_unavailable(True)
try: try:
_profile_controller_request("POST", "/workers/music/start") path = ("/workers/music/start" if special_mode == "music"
_wait_music_ready() else "/workers/separator/start")
_profile_controller_request("POST", path)
_wait_music_ready() if special_mode == "music" else _wait_separator_ready()
STATE.mode_phase = "ready" STATE.mode_phase = "ready"
RUNTIME.save(mode="music", phase="music") RUNTIME.save(mode=special_mode, phase=special_mode)
log.info("Recovery: Musikmodus wiederhergestellt") log.info("Recovery: Spezialmodus %s wiederhergestellt", special_mode)
except Exception as exc: except Exception as exc:
STATE.mode_error = str(exc) STATE.mode_error = str(exc)
STATE.mode_phase = "error" 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 return
profile = current_profile() profile = current_profile()