Add BS-RoFormer vocal separation mode
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user