From f2052adb78fd844074c39aa77f92d66eb1131d8e Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:53:19 +0200 Subject: [PATCH] Integrate YuE2 as an exclusive Athena mode --- ATHENA.md | 4 + Erklärung.txt | 5 + README.md | 1 + compose.yaml | 6 + dev/test_dashboard_modes.py | 16 +++ dev/test_profile_controller.py | 29 +++++ docs/CONTAINER_INVENTORY.md | 1 + docs/OPERATING_MODES.md | 19 ++- experiments/yue2-3b/compose.yaml | 11 ++ for_ki.md | 8 ++ .../profile-controller/profile_controller.py | 123 ++++++++++++++++++ .../docker/wireguard-gateway/entrypoint.sh | 2 + platform/llama-dashboard/app.py | 15 ++- router/ai_profile_router.py | 53 +++++++- 14 files changed, 278 insertions(+), 15 deletions(-) diff --git a/ATHENA.md b/ATHENA.md index 35fb7a0..c6c120e 100644 --- a/ATHENA.md +++ b/ATHENA.md @@ -13,6 +13,7 @@ Sie betreibt: - FLUX.2 Klein 9B FP8 Beta für Textbilder und Referenzbild-Bearbeitung, - Qwen3-TTS für Sprache, - ACE-Step 1.5 XL-SFT als exklusiven Musikstudio-Modus, +- YuE2-3B mit Ladypoly-WebUI als zweiten, getrennten Musikstudio-Modus, - TRELLIS.2 4B Q8 als exklusives Bild-zu-3D-Studio, - das Athena-Dashboard, - Portainer CE als optionale Ansicht auf die laufenden Docker-Container, @@ -31,6 +32,7 @@ werden keine zweiten Instanzen dieser Dienste angelegt. | `/data/llama-dashboard` | historische Dashboard-Messwerte | | `/data/docker-backups` | automatische Athena-Backups | | `/data/trellis-studio` | trellis.cpp-Runtime und erzeugte 3D-Modelle | +| `/data/models/yue2`, `/data/music/yue2` | YuE2-Gewichte und dauerhafte Ergebnisse | | `/etc/mike-ai` | lokale Konfiguration und Secrets, niemals Git | Portainer läuft als separater, optionaler Verwaltungscontainer @@ -74,6 +76,8 @@ Qwen-Profil wird vom Profile Controller verwaltet. - ACE-Step 1.5 XL-SFT: exklusiver Musikmodus auf der RTX 5080. Dashboard und die Routerbefehle `/athena music`, `/athena llm`, `/athena status` bedienen dieselbe persistente Zustandsmaschine; siehe `docs/OPERATING_MODES.md`. +- YuE2-3B: eigener exklusiver Musikmodus mit Score- und Remix-Funktionen unter + `http://192.168.1.212:8014`. Der Routerbefehl lautet `/athena yue2`. - TRELLIS.2 4B Q8: exklusives Bild-zu-3D-Profil auf der RTX 5080. Die browserbasierte Oberfläche läuft unter `http://192.168.1.212:8013`, erzeugt GLB und verwendet standardmäßig `1024 · cascade`. Der 1536er Pfad kann die diff --git a/Erklärung.txt b/Erklärung.txt index a842a26..32f513c 100644 --- a/Erklärung.txt +++ b/Erklärung.txt @@ -93,6 +93,9 @@ Betriebsmodus gestartet. Zum Zeitpunkt der Aufnahme war Applio/RVC aktiv. | mike-ai-music-ui | läuft | Community-Weboberfläche für ACE-Step; das | | | | eigentliche Musikmodell wird separat geladen.| +-----------------------------------+-------------------+----------------------------------------------+ +| mike-ai-yue2-playground | gestoppt/bereit | Eigenständiges YuE2-Musikstudio mit Score-, | +| | | Generierungs- und Remix-Funktionen auf :8014.| ++-----------------------------------+-------------------+----------------------------------------------+ | mike-ai-stem-separator | gestoppt/bereit | Trennt Gesang, Begleitung und Instrumente | | | | mit BS-RoFormer und Demucs. | +-----------------------------------+-------------------+----------------------------------------------+ @@ -185,6 +188,8 @@ benötigten Modelle. +---------------+------------------------------------------------------------+ | Musik | ACE-Step 1.5 für Musikgenerierung. | +---------------+------------------------------------------------------------+ +| YuE2 Studio | YuE2-3B für Musik, Score-Steuerung und Audio-Remix. | ++---------------+------------------------------------------------------------+ | Audio trennen | BS-RoFormer, Demucs oder MossFormer2. | +---------------+------------------------------------------------------------+ | Voice Studio | OmniVoice für referenzbasierte Text-zu-Sprache-Ausgabe. | diff --git a/README.md b/README.md index 5fda660..0b50818 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Details, Installation, Prüfung und Rollback stehen in - Applio (RVC-Inferenz, Modelle und Training): `http://192.168.1.212:8011` - Mikes Applio UI (geführte RVC-Oberfläche): `http://192.168.1.212:8012` - 3D Studio (TRELLIS.2 Q8, GLB-Ausgabe): `http://192.168.1.212:8013` +- YuE2 Studio (YuE2-3B, Generierung und Audio-Remix): `http://192.168.1.212:8014` Der Betriebsmodus lässt sich dort direkt umschalten. In Hermes funktionieren außerdem `/athena music`, `/athena stems`, `/athena voice`, diff --git a/compose.yaml b/compose.yaml index ed3f58e..8d390f9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -487,10 +487,12 @@ services: RESTORE_WORKER: restore TTS_WORKER: qwen3 MUSIC_WORKER: acestep + YUE2_WORKER: yue2 SEPARATOR_WORKER: bs-roformer VOICE_WORKER: vevo2 VOICE_CHANGE_WORKER: xvc APPLIO_WORKER: applio + TRELLIS_WORKER: trellis2-q8 networks: [control] security_opt: ["no-new-privileges:true"] healthcheck: @@ -526,6 +528,8 @@ services: PROFILE_CONTROL_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}" SWITCH_TIMEOUT: "600" REQUEST_TIMEOUT: "600" + YUE2_START_TIMEOUT: "600" + TRELLIS_START_TIMEOUT: "900" # Last-resort guard for every OpenAI-compatible client. Without a # request limit llama.cpp uses n_predict=-1 and a reasoning loop can # consume the complete context before yielding visible output. @@ -748,6 +752,8 @@ services: VOICE_CHANGE_UI_URL: "${VOICE_CHANGE_UI_URL:-http://192.168.1.212:8009/}" APPLIO_UI_URL: "${APPLIO_UI_URL:-http://192.168.1.212:8011/}" MIKES_APPLIO_UI_URL: "${MIKES_APPLIO_UI_URL:-http://192.168.1.212:8012/}" + TRELLIS_UI_URL: "${TRELLIS_UI_URL:-http://192.168.1.212:8013/}" + YUE2_UI_URL: "${YUE2_UI_URL:-http://192.168.1.212:8014/}" HOST_PROC: /host/proc HOST_DATA: /host/data HOST_MODELS: /host/models diff --git a/dev/test_dashboard_modes.py b/dev/test_dashboard_modes.py index 08e92bd..0aaf214 100644 --- a/dev/test_dashboard_modes.py +++ b/dev/test_dashboard_modes.py @@ -80,6 +80,13 @@ class DashboardModeTests(unittest.TestCase): self.assertEqual(body, {"status": "accepted"}) self.assertEqual(json.loads(urlopen.call_args.args[0].data), {"mode": "applio"}) + def test_yue2_mode_is_forwarded_to_router(self): + with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen: + status, body = self.dashboard.change_mode("yue2") + self.assertEqual(status, 202) + self.assertEqual(body, {"status": "accepted"}) + self.assertEqual(json.loads(urlopen.call_args.args[0].data), {"mode": "yue2"}) + def test_unknown_mode_is_rejected_without_router_request(self): with patch.object(self.dashboard.urllib.request, "urlopen") as urlopen: status, body = self.dashboard.change_mode("unknown") @@ -104,6 +111,15 @@ class DashboardModeTests(unittest.TestCase): self.assertIn("http://192.168.1.212:8011/", html) self.assertIn("http://192.168.1.212:8012/", html) + def test_dashboard_keeps_ace_step_and_offers_yue2_separately(self): + html = self.dashboard.HTML + + self.assertIn("ACE-Step Studio", html) + self.assertIn("YuE2 Studio", html) + self.assertIn("setMode('music')", html) + self.assertIn("setMode('yue2')", html) + self.assertIn("http://192.168.1.212:8014/", html) + def test_dashboard_lists_only_portable_encrypted_backups(self): valid = self.backup_dir / "athena-portable-2026-09-10T10-00-00Z.tar.zst.age" valid.write_bytes(b"encrypted") diff --git a/dev/test_profile_controller.py b/dev/test_profile_controller.py index b9c6d00..5c515e5 100644 --- a/dev/test_profile_controller.py +++ b/dev/test_profile_controller.py @@ -41,6 +41,11 @@ def music_item(state="exited"): "Labels": {controller.MUSIC_LABEL_KEY: "acestep"}} +def yue2_item(state="exited"): + return {"Id": "id-yue2", "State": state, + "Labels": {controller.MUSIC_LABEL_KEY: "yue2"}} + + def separator_item(state="exited"): return {"Id": "id-separator", "State": state, "Labels": {controller.SEPARATOR_LABEL_KEY: "bs-roformer"}} @@ -57,6 +62,30 @@ def voice_change_item(state="exited"): class ProfileControllerTests(unittest.TestCase): + def test_yue2_start_exclusively_stops_llm_and_ace_step(self): + profiles = {name: item(name) for name in controller.ALLOWED} + profiles["ultra"] = item("ultra", "running") + calls = [] + + def request(method, path): + calls.append((method, path)) + return 204, b"" + + with patch.object(controller, "YUE2_WORKER", "yue2"), \ + patch.object(controller, "MUSIC_WORKER", "acestep"), \ + patch.object(controller, "containers", return_value=profiles), \ + patch.object(controller, "yue2_container", return_value=yue2_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_yue2_worker(True) + + self.assertEqual(result, {"yue2_worker": "yue2", "state": "running"}) + self.assertIn(("POST", "/containers/id-ultra/stop?t=120"), calls) + self.assertIn(("POST", "/containers/id-music/stop?t=30"), calls) + self.assertEqual(calls[-1], ("POST", "/containers/id-yue2/start")) + def test_voice_change_start_exclusively_stops_gpu_workers(self): profiles = {name: item(name) for name in controller.ALLOWED} profiles["medium"] = item("medium", "running") diff --git a/docs/CONTAINER_INVENTORY.md b/docs/CONTAINER_INVENTORY.md index 09a6761..8917516 100644 --- a/docs/CONTAINER_INVENTORY.md +++ b/docs/CONTAINER_INVENTORY.md @@ -28,6 +28,7 @@ nicht automatisch ein ungenutzter Rest. | `mike-ai-router` | kein eigenes Modell | Einzige OpenAI-kompatible Modelladresse; koordiniert Profile, Bildaufträge, Sprache und Betriebsarten. | | `mike-ai-stem-separator` | BS-RoFormer Viperx 1297, `htdemucs_ft`, `htdemucs_6s`, `MossFormer2_SE_48K` | Trennt Gesang, Instrumente oder Sprache/Hintergrundgeräusche im exklusiven Separationsmodus. | | `mike-ai-trellis-studio` | TRELLIS.2 4B Q8 über trellis.cpp 0.6.0 | Erzeugt im exklusiven 3D-Modus aus einem Bild ein texturiertes, geschlossen aufbereitetes GLB-Mesh. Nutzt ausschließlich die RTX 5080 und wird über Port 8013 bedient. | +| `mike-ai-yue2-playground` | YuE2-3B mit Ladypoly `YuE2_WebUI` | Eigenständiges Musikstudio für Generierung, Score-basierte Steuerung und SheetSage2-Audioanalyse/Remix. Läuft exklusiv zu ACE-Step und allen übrigen GPU-Diensten; Zugriff über Port 8014. | | `mike-ai-tts-gateway` | kein eigenes Modell | Normalisiert Text, konvertiert Ausgabeformate und stellt Qwen3-TTS sowie natives PCM-Streaming über eine stabile interne API bereit. | | `mike-ai-voice-studio` | `k2-fsa/OmniVoice` 0.2.1 mit Whisper-ASR | Erzeugt Text-to-Speech mit einer Referenzstimme; kein Audio-to-Audio-Voice-Changer. | | `mike-ai-whisper` | Whisper.cpp `ggml-small` | Lokale deutsche Spracherkennung auf der CPU über `/v1/audio/transcriptions`. | diff --git a/docs/OPERATING_MODES.md b/docs/OPERATING_MODES.md index e918e0b..3a245f5 100644 --- a/docs/OPERATING_MODES.md +++ b/docs/OPERATING_MODES.md @@ -1,9 +1,11 @@ # Athena-Betriebsmodi -Athena besitzt sieben gegenseitig exklusive Betriebsmodi: +Athena besitzt acht gegenseitig exklusive Betriebsmodi: - `llm`: ein llama.cpp-Profil und Qwen3-TTS laufen; Spezialdienste sind gestoppt. - `music`: ACE-Step 1.5 XL-SFT läuft; alle LLM-, Bild-, TTS- und Separator-Worker sind gestoppt. +- `yue2`: YuE2-3B und die angepasste `YuE2_WebUI` laufen; alle anderen + GPU-Dienste einschließlich ACE-Step sind gestoppt. - `separation`: BS-RoFormer und Demucs trennen Musikspuren; ClearVoice trennt Sprache von Hintergrundgeräuschen. LLM, Bild, TTS und ACE-Step sind gestoppt. - `voice`: OmniVoice erzeugt Sprache aus Text mit einer gewählten @@ -23,7 +25,7 @@ wird persistent gespeichert und beim Verlassen eines Spezialmodus wieder geladen ## Bedienung -Im Athena-Dashboard stehen **LLM-Betrieb**, **Musikstudio**, **Audio trennen**, +Im Athena-Dashboard stehen **LLM-Betrieb**, **ACE-Step Studio**, **YuE2 Studio**, **Audio trennen**, **Voice Studio**, **X-VC**, **Applio / RVC** und **3D Studio** bereit. Im Musikmodus werden zwei Oberflächen angeboten: - **Original UI · stabil** öffnet die zum laufenden ACE-Step-Image gehörende @@ -47,6 +49,15 @@ veröffentlicht. `ace-step-ui` ist reproduzierbar auf Commit `a1fdf91829ec6f7b98844f80e323529cd155dbf2` fixiert und greift intern über das Docker-Netz `mike-ai-music` auf `http://music-worker:7860` zu. +Das getrennte **YuE2 Studio** ist über WireGuard unter +`http://192.168.1.212:8014` erreichbar. Es verwendet YuE2-3B und die auf einen +festen Commit gesetzte `YuE2_WebUI` von Ladypoly. Analyse/Remix über +SheetSage2, Score-Übernahme und freie Generierung bleiben damit unabhängig vom +ACE-Step-Stack. Der Container trägt das Router-Label +`com.mike-ai.music-worker=yue2` und hängt als `yue2-studio` im privaten +Frontend-Netz. Ein Moduswechsel stoppt ihn zuverlässig, bevor LLM, +Audio-Trenner oder ein anderer GPU-Dienst gestartet werden. + 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. Gewählt wird die herauszulösende Quelle: Gesang, @@ -98,6 +109,7 @@ Router lokal beantwortet, auch wenn gerade kein LLM geladen ist: ```text /athena music +/athena yue2 /athena stems /athena voice /athena voicechange @@ -113,6 +125,7 @@ Die HTTP-Schnittstelle verwendet authentifizierte Requests: ```text GET /mode POST /mode {"mode":"music"} +POST /mode {"mode":"yue2"} POST /mode {"mode":"separation"} POST /mode {"mode":"voice"} POST /mode {"mode":"voicechange"} @@ -123,7 +136,7 @@ POST /mode {"mode":"llm"} Der Wechsel läuft asynchron. Fortschritt und Fehler stehen unter `mode` in `GET /status`. Der Profile-Controller akzeptiert ausschließlich den mit -`com.mike-ai.music-worker=acestep` beziehungsweise +`com.mike-ai.music-worker=acestep` oder `com.mike-ai.music-worker=yue2` beziehungsweise `com.mike-ai.stem-separator=bs-roformer` oder `com.mike-ai.voice-worker=vevo2` beziehungsweise `com.mike-ai.voice-change-worker=xvc` oder diff --git a/experiments/yue2-3b/compose.yaml b/experiments/yue2-3b/compose.yaml index d76a35b..0c60a2f 100644 --- a/experiments/yue2-3b/compose.yaml +++ b/experiments/yue2-3b/compose.yaml @@ -33,6 +33,8 @@ services: image: mike-ai/yue2:3b-0.1.6 container_name: mike-ai-yue2-playground restart: "no" + labels: + com.mike-ai.music-worker: yue2 entrypoint: ["python", "/opt/yue2/webui/server.py"] environment: NVIDIA_VISIBLE_DEVICES: "GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe" @@ -44,6 +46,9 @@ services: TRANSFORMERS_OFFLINE: "1" ports: - "127.0.0.1:8014:8014" + networks: + frontend: + aliases: [yue2-studio] volumes: - /data/models/yue2:/opt/yue2/models:ro - /data/music/yue2:/workspace/runs @@ -54,6 +59,7 @@ services: - driver: nvidia device_ids: ["GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"] capabilities: [gpu] + healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8014/api/state', timeout=2)"] interval: 5s @@ -91,3 +97,8 @@ services: - driver: nvidia device_ids: ["GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"] capabilities: [gpu] + +networks: + frontend: + external: true + name: mike-ai_frontend diff --git a/for_ki.md b/for_ki.md index 6d33b5d..83d9103 100644 --- a/for_ki.md +++ b/for_ki.md @@ -100,12 +100,20 @@ Wechsel zu `llm` wird das zuvor gemerkte Profil wiederhergestellt. | `llm` | ein Qwen-Profil + Qwen3-TTS | profilabhängig beide GPUs; TTS RTX 3060 | Router `:8081` | | Bildauftrag | FLUX.2 Klein 9B FP8 + Qwen3-8B NF4 | RTX 5080 + RTX 3060, transaktional | über Router | | `music` | ACE-Step 1.5 XL-SFT | RTX 5080 | `:7862` original, `:7861` Community | +| `yue2` | YuE2-3B + Ladypoly `YuE2_WebUI` | RTX 5080 | `:8014` | | `separation` | BS-RoFormer, Demucs, MossFormer2 | RTX 5080 | `:8007` | | `voice` | OmniVoice | RTX 5080 | `:8008` | | `voicechange` | X-VC + optional Resemble Enhance | RTX 5080 | `:8009` | | `applio` | Applio/RVC | RTX 5080 | `:8011`, eigene UI `:8012` | | `trellis` | TRELLIS.2 4B Q8 über trellis.cpp 0.6.0 | ausschließlich RTX 5080 | `:8013` | +YuE2 liegt unter `/opt/mike-ai/yue2-3b`, seine Gewichte unter +`/data/models/yue2` und Ergebnisse unter `/data/music/yue2`. Der Container +trägt `com.mike-ai.music-worker=yue2` und muss mit dem Alias `yue2-studio` am +externen Netz `mike-ai_frontend` hängen. YuE2 niemals außerhalb der +Router-Zustandsmaschine dauerhaft starten: Sonst bleibt sein VRAM belegt und +der nächste LLM- oder Separator-Start kann mit OOM scheitern. + TRELLIS liegt unter `/opt/mike-ai/trellis-studio`. Seine Q8-Gewichte liegen unter `/data/models/trellis2-q8`, die Runtime und Ausgaben unter `/data/trellis-studio`. Die Oberfläche liefert GLB. `1024 · cascade` ist der diff --git a/platform/docker/profile-controller/profile_controller.py b/platform/docker/profile-controller/profile_controller.py index 2ff1de0..dbf4c6f 100644 --- a/platform/docker/profile-controller/profile_controller.py +++ b/platform/docker/profile-controller/profile_controller.py @@ -30,6 +30,7 @@ TTS_LABEL_KEY = "com.mike-ai.tts-worker" TTS_WORKER = os.environ.get("TTS_WORKER", "qwen3") MUSIC_LABEL_KEY = "com.mike-ai.music-worker" MUSIC_WORKER = os.environ.get("MUSIC_WORKER", "").strip() +YUE2_WORKER = os.environ.get("YUE2_WORKER", "").strip() SEPARATOR_LABEL_KEY = "com.mike-ai.stem-separator" SEPARATOR_WORKER = os.environ.get("SEPARATOR_WORKER", "").strip() VOICE_LABEL_KEY = "com.mike-ai.voice-worker" @@ -38,6 +39,8 @@ VOICE_CHANGE_LABEL_KEY = "com.mike-ai.voice-change-worker" VOICE_CHANGE_WORKER = os.environ.get("VOICE_CHANGE_WORKER", "").strip() APPLIO_LABEL_KEY = "com.mike-ai.applio-worker" APPLIO_WORKER = os.environ.get("APPLIO_WORKER", "").strip() +TRELLIS_LABEL_KEY = "com.mike-ai.trellis-worker" +TRELLIS_WORKER = os.environ.get("TRELLIS_WORKER", "").strip() LOCK = threading.Lock() log = logging.getLogger("profile-controller") @@ -117,6 +120,17 @@ def music_container() -> dict: return matches[0] +def yue2_container() -> dict: + if not YUE2_WORKER: + raise RuntimeError("YuE2 worker is not configured") + matches = [item for item in labelled_containers(MUSIC_LABEL_KEY) + if item.get("Labels", {}).get(MUSIC_LABEL_KEY) == YUE2_WORKER] + if len(matches) != 1: + raise RuntimeError( + f"expected exactly one YuE2 worker {YUE2_WORKER!r}, found {len(matches)}") + return matches[0] + + def separator_container() -> dict: if not SEPARATOR_WORKER: raise RuntimeError("stem separator is not configured") @@ -161,11 +175,27 @@ def applio_container() -> dict: return matches[0] +def trellis_container() -> dict: + if not TRELLIS_WORKER: + raise RuntimeError("TRELLIS worker is not configured") + matches = [item for item in labelled_containers(TRELLIS_LABEL_KEY) + if item.get("Labels", {}).get(TRELLIS_LABEL_KEY) == TRELLIS_WORKER] + if len(matches) != 1: + raise RuntimeError( + f"expected exactly one TRELLIS worker {TRELLIS_WORKER!r}, found {len(matches)}") + return matches[0] + + def stop_music_if_configured() -> None: if MUSIC_WORKER: stop_container(music_container(), timeout=30) +def stop_yue2_if_configured() -> None: + if YUE2_WORKER: + stop_container(yue2_container(), timeout=30) + + def stop_separator_if_configured() -> None: if SEPARATOR_WORKER: stop_container(separator_container(), timeout=30) @@ -186,6 +216,11 @@ def stop_applio_if_configured() -> None: stop_container(applio_container(), timeout=30) +def stop_trellis_if_configured() -> None: + if TRELLIS_WORKER: + stop_container(trellis_container(), timeout=30) + + def stop_voice_tools(except_kind: str | None = None) -> None: if except_kind != "voice": stop_voice_if_configured() @@ -233,8 +268,10 @@ def set_image_worker(running: bool, kind: str = IMAGE_WORKER) -> dict: # Qwen3-TTS. TTS is unavailable during this exclusive GPU phase. stop_container(tts_container(), timeout=30) stop_music_if_configured() + stop_yue2_if_configured() stop_separator_if_configured() stop_voice_tools() + stop_trellis_if_configured() for other in image_containers(): if other["Id"] != item["Id"]: stop_container(other, timeout=20) @@ -262,6 +299,8 @@ def set_music_worker(running: bool) -> dict: stop_container(tts_container(), timeout=30) stop_separator_if_configured() stop_voice_tools() + stop_yue2_if_configured() + stop_trellis_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -269,6 +308,27 @@ def set_music_worker(running: bool) -> dict: "state": "running" if running else "stopped"} +def set_yue2_worker(running: bool) -> dict: + """Start YuE2 exclusively, or stop it before another mode is loaded.""" + with LOCK: + item = yue2_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() + stop_separator_if_configured() + stop_voice_tools() + stop_trellis_if_configured() + start_container(item) + else: + stop_container(item, timeout=30) + return {"yue2_worker": YUE2_WORKER, + "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: @@ -280,7 +340,9 @@ def set_separator_worker(running: bool) -> dict: stop_container(worker, timeout=20) stop_container(tts_container(), timeout=30) stop_music_if_configured() + stop_yue2_if_configured() stop_voice_tools() + stop_trellis_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -299,8 +361,10 @@ def set_voice_worker(running: bool) -> dict: stop_container(worker, timeout=20) stop_container(tts_container(), timeout=30) stop_music_if_configured() + stop_yue2_if_configured() stop_separator_if_configured() stop_voice_tools("voice") + stop_trellis_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -319,8 +383,10 @@ def set_voice_change_worker(running: bool) -> dict: stop_container(worker, timeout=20) stop_container(tts_container(), timeout=30) stop_music_if_configured() + stop_yue2_if_configured() stop_separator_if_configured() stop_voice_tools("voicechange") + stop_trellis_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -339,8 +405,10 @@ def set_applio_worker(running: bool) -> dict: stop_container(worker, timeout=20) stop_container(tts_container(), timeout=30) stop_music_if_configured() + stop_yue2_if_configured() stop_separator_if_configured() stop_voice_tools("applio") + stop_trellis_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -348,6 +416,27 @@ def set_applio_worker(running: bool) -> dict: "state": "running" if running else "stopped"} +def set_trellis_worker(running: bool) -> dict: + """Start TRELLIS.2 exclusively, or stop it before another mode is loaded.""" + with LOCK: + item = trellis_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() + stop_yue2_if_configured() + stop_separator_if_configured() + stop_voice_tools() + start_container(item) + else: + stop_container(item, timeout=30) + return {"trellis_worker": TRELLIS_WORKER, + "state": "running" if running else "stopped"} + + def active_profile(items: dict[str, dict] | None = None) -> str | None: items = items or containers() active = [name for name, item in items.items() if item.get("State") == "running"] @@ -364,8 +453,10 @@ def activate(profile: str) -> dict: for worker in image_containers(): stop_container(worker) stop_music_if_configured() + stop_yue2_if_configured() stop_separator_if_configured() stop_voice_tools() + stop_trellis_if_configured() start_container(tts_container()) items = containers() missing = [name for name in ALLOWED if name not in items] @@ -437,6 +528,13 @@ class Handler(BaseHTTPRequestHandler): "unhealthy" if "(unhealthy)" in music_status else "starting" if music.get("State") == "running" else "stopped") + yue2 = yue2_container() if YUE2_WORKER else {} + yue2_status = yue2.get("Status", "") + yue2_health = ("disabled" if not YUE2_WORKER else + "healthy" if "(healthy)" in yue2_status else + "unhealthy" if "(unhealthy)" in yue2_status else + "starting" if yue2.get("State") == "running" else + "stopped") separator = separator_container() if SEPARATOR_WORKER else {} separator_status = separator.get("Status", "") separator_health = ("disabled" if not SEPARATOR_WORKER else @@ -465,9 +563,18 @@ class Handler(BaseHTTPRequestHandler): "unhealthy" if "(unhealthy)" in applio_status else "starting" if applio.get("State") == "running" else "stopped") + trellis = trellis_container() if TRELLIS_WORKER else {} + trellis_status = trellis.get("Status", "") + trellis_health = ("disabled" if not TRELLIS_WORKER else + "healthy" if "(healthy)" in trellis_status else + "unhealthy" if "(unhealthy)" in trellis_status else + "starting" if trellis.get("State") == "running" else + "stopped") self.reply(200, {"active_profile": active_profile(items), "music_worker": music.get("State", "disabled"), "music_health": music_health, + "yue2_worker": yue2.get("State", "disabled"), + "yue2_health": yue2_health, "separator_worker": separator.get("State", "disabled"), "separator_health": separator_health, "voice_worker": voice.get("State", "disabled"), @@ -476,6 +583,8 @@ class Handler(BaseHTTPRequestHandler): "voice_change_health": voice_change_health, "applio_worker": applio.get("State", "disabled"), "applio_health": applio_health, + "trellis_worker": trellis.get("State", "disabled"), + "trellis_health": trellis_health, "profiles": {name: items.get(name, {}).get( "State", "missing") for name in ALLOWED}}) except Exception as exc: @@ -500,6 +609,13 @@ class Handler(BaseHTTPRequestHandler): log.exception("music worker transition failed") self.reply(503, {"error": str(exc)}) return + if self.path in {"/workers/yue2/start", "/workers/yue2/stop"}: + try: + self.reply(200, set_yue2_worker(self.path.endswith("/start"))) + except Exception as exc: + log.exception("YuE2 worker transition failed") + self.reply(503, {"error": str(exc)}) + return if self.path in {"/workers/separator/start", "/workers/separator/stop"}: try: self.reply(200, set_separator_worker(self.path.endswith("/start"))) @@ -528,6 +644,13 @@ class Handler(BaseHTTPRequestHandler): log.exception("Applio worker transition failed") self.reply(503, {"error": str(exc)}) return + if self.path in {"/workers/trellis/start", "/workers/trellis/stop"}: + try: + self.reply(200, set_trellis_worker(self.path.endswith("/start"))) + except Exception as exc: + log.exception("TRELLIS worker transition failed") + self.reply(503, {"error": str(exc)}) + return worker_paths = { "/workers/image/start": (IMAGE_WORKER, True), "/workers/image/stop": (IMAGE_WORKER, False), diff --git a/platform/docker/wireguard-gateway/entrypoint.sh b/platform/docker/wireguard-gateway/entrypoint.sh index 30e712e..34c2043 100644 --- a/platform/docker/wireguard-gateway/entrypoint.sh +++ b/platform/docker/wireguard-gateway/entrypoint.sh @@ -84,6 +84,8 @@ start_proxy 8008 voice-studio:8008 start_proxy 8009 xvc-studio:8009 start_proxy 8011 applio-studio:6969 start_proxy 8012 mikes-applio-ui:8012 +start_proxy 8013 trellis-studio:8080 +start_proxy 8014 yue2-studio:8014 start_proxy 8202 mcp-athena-operator:8000 start_proxy 9443 portainer:9443 diff --git a/platform/llama-dashboard/app.py b/platform/llama-dashboard/app.py index 6215a86..2f5a925 100644 --- a/platform/llama-dashboard/app.py +++ b/platform/llama-dashboard/app.py @@ -34,6 +34,8 @@ APPLIO_UI_URL = os.getenv("APPLIO_UI_URL", "http://192.168.1.212:8011/") MIKES_APPLIO_UI_URL = os.getenv( "MIKES_APPLIO_UI_URL", "http://192.168.1.212:8012/" ) +TRELLIS_UI_URL = os.getenv("TRELLIS_UI_URL", "http://192.168.1.212:8013/") +YUE2_UI_URL = os.getenv("YUE2_UI_URL", "http://192.168.1.212:8014/") HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc")) HOST_DATA = os.getenv("HOST_DATA", "/host/data") HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models")) @@ -311,7 +313,8 @@ def router_status() -> tuple[dict[str, Any], str | None]: def change_mode(mode: str) -> tuple[int, dict[str, Any]]: - if mode not in {"llm", "music", "separation", "voice", "voicechange", "applio"}: + if mode not in {"llm", "music", "yue2", "separation", "voice", + "voicechange", "applio", "trellis"}: return 400, {"error": "invalid mode"} headers = {"Accept": "application/json", "Content-Type": "application/json"} if ROUTER_API_KEY: @@ -670,7 +673,7 @@ HTML = r'''
Mike AI · Live Telemetry

Athena llama.cpp Dashboard

verbinde …
- +
Aktives Profil
–
Router wird abgefragt
Modell
–
–
CPU
–
–
@@ -711,8 +714,8 @@ const $=id=>document.getElementById(id); const pct=n=>n==null?'–':`${n.toFixed function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0));return `
GPU ${g.index}
${g.name}
${g.pstate||'–'}
${pct(g.gpu_percent)}GPU-Kern
${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiBVRAM
${g.temperature_c??'–'} °CTemperatur
${g.power_w??'–'} / ${g.power_limit_w??'–'} WLeistung
${g.graphics_clock_mhz??'–'} MHzGrafiktakt
${g.memory_clock_mhz??'–'} MHzSpeichertakt
${pct(g.memory_controller_percent)}Memory Controller
${pct(g.fan_percent)}Lüfter
GPU-Auslastung${load.toFixed(1)} %
VRAM-Belegung${p.toFixed(1)} %
${(g.memory_free_mib/1024).toFixed(1)} GiB VRAM frei
`} const imagePhaseLabel=p=>({"stopping-qwen":"Qwen wird entladen","loading-image":"Bildmodell wird geladen","generating":"Bild wird generiert","unloading-image":"Bildmodell wird entladen","restoring-qwen":"Qwen wird wiederhergestellt"}[p]||p||'bereit'); let modeBusy=false; -async function setMode(mode){if(modeBusy)return;modeBusy=true;for(const id of ['llmMode','musicMode','separationMode','voiceMode','voiceChangeMode','applioMode'])$(id).disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}} -async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=({llm:'LLM-Betrieb',music:'Musikstudio',separation:'Stimmtrennung',voice:'Voice Studio',voicechange:'X-VC Voice Changer',applio:'Applio / RVC'})[md.active]||'Unbekannt';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`Musik: ${md.music_worker||'–'} · Separator: ${md.separator_worker||'–'} · Voice: ${md.voice_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');$('voiceMode').classList.toggle('active',md.active==='voice');$('voiceChangeMode').classList.toggle('active',md.active==='voicechange');$('applioMode').classList.toggle('active',md.active==='applio');let modeControlsBusy=modeBusy||switchingMode||!md.enabled;for(const id of ['llmMode','musicMode','separationMode','voiceMode','voiceChangeMode','applioMode'])$(id).disabled=modeControlsBusy;$('musicOpen').hidden=md.active!=='music';$('separatorOpen').hidden=md.active!=='separation';$('voiceOpen').hidden=md.active!=='voice';$('voiceChangeOpen').hidden=md.active!=='voicechange';$('applioOpen').hidden=md.active!=='applio';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'
Keine GPU-Daten verfügbar
';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}${p.name}${p.pid}${p.memory_mib??'–'} MiB`).join('')||'Keine Compute-Prozesse gemeldet';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`
${v??'–'}${k}
`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000); +async function setMode(mode){if(modeBusy)return;modeBusy=true;for(const id of ['llmMode','musicMode','yue2Mode','separationMode','voiceMode','voiceChangeMode','applioMode','trellisMode'])$(id).disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}} +async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=({llm:'LLM-Betrieb',music:'ACE-Step Studio',yue2:'YuE2 Studio',separation:'Stimmtrennung',voice:'Voice Studio',voicechange:'X-VC Voice Changer',applio:'Applio / RVC',trellis:'3D Studio'})[md.active]||'Unbekannt';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`ACE-Step: ${md.music_worker||'–'} · YuE2: ${md.yue2_worker||'–'} · Separator: ${md.separator_worker||'–'} · Voice: ${md.voice_worker||'–'} · 3D: ${md.trellis_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');$('yue2Mode').classList.toggle('active',md.active==='yue2');$('separationMode').classList.toggle('active',md.active==='separation');$('voiceMode').classList.toggle('active',md.active==='voice');$('voiceChangeMode').classList.toggle('active',md.active==='voicechange');$('applioMode').classList.toggle('active',md.active==='applio');$('trellisMode').classList.toggle('active',md.active==='trellis');let modeControlsBusy=modeBusy||switchingMode||!md.enabled;for(const id of ['llmMode','musicMode','yue2Mode','separationMode','voiceMode','voiceChangeMode','applioMode','trellisMode'])$(id).disabled=modeControlsBusy;$('musicOpen').hidden=md.active!=='music';$('yue2Open').hidden=md.active!=='yue2';$('separatorOpen').hidden=md.active!=='separation';$('voiceOpen').hidden=md.active!=='voice';$('voiceChangeOpen').hidden=md.active!=='voicechange';$('applioOpen').hidden=md.active!=='applio';$('trellisOpen').hidden=md.active!=='trellis';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'
Keine GPU-Daten verfügbar
';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}${p.name}${p.pid}${p.memory_mib??'–'} MiB`).join('')||'Keine Compute-Prozesse gemeldet';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`
${v??'–'}${k}
`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000); async function refreshBackups(){try{let r=await fetch('/api/backups',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),rows=d.backups||[];$('backupFiles').innerHTML=rows.map(b=>`${new Date(b.modified*1000).toLocaleString('de-DE')}${gib(b.size)}${b.sha256||'Prüfsumme fehlt'}Herunterladen`).join('')||'Noch kein portables Backup vorhanden';$('backupNote').textContent=rows.length?`${rows.length} von maximal 5 Generationen · verschlüsselt mit Age`:'Der erste Lauf startet spätestens fünf Stunden nach Aktivierung.'}catch(e){$('backupNote').textContent=`Backup-Liste nicht verfügbar: ${e.message}`}}refreshBackups();setInterval(refreshBackups,60000); '''.replace( "__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL @@ -721,7 +724,9 @@ async function refreshBackups(){try{let r=await fetch('/api/backups',{cache:'no- ).replace("__VOICE_UI_URL__", VOICE_UI_URL ).replace("__VOICE_CHANGE_UI_URL__", VOICE_CHANGE_UI_URL ).replace("__APPLIO_UI_URL__", APPLIO_UI_URL -).replace("__MIKES_APPLIO_UI_URL__", MIKES_APPLIO_UI_URL) +).replace("__MIKES_APPLIO_UI_URL__", MIKES_APPLIO_UI_URL).replace( + "__TRELLIS_UI_URL__", TRELLIS_UI_URL +).replace("__YUE2_UI_URL__", YUE2_UI_URL) FULL_JS = r''' diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index 6ce51d7..9eba694 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -108,10 +108,12 @@ PROFILE_CONTROL_TOKEN_FILE = os.environ.get( ENABLE_MUSIC_MODE = os.environ.get( "ENABLE_MUSIC_MODE", "false").lower() in {"1", "true", "yes"} MUSIC_START_TIMEOUT = float(os.environ.get("MUSIC_START_TIMEOUT", "600")) +YUE2_START_TIMEOUT = float(os.environ.get("YUE2_START_TIMEOUT", "600")) SEPARATOR_START_TIMEOUT = float(os.environ.get("SEPARATOR_START_TIMEOUT", "600")) VOICE_START_TIMEOUT = float(os.environ.get("VOICE_START_TIMEOUT", "600")) VOICE_CHANGE_START_TIMEOUT = float(os.environ.get("VOICE_CHANGE_START_TIMEOUT", "600")) APPLIO_START_TIMEOUT = float(os.environ.get("APPLIO_START_TIMEOUT", "900")) +TRELLIS_START_TIMEOUT = float(os.environ.get("TRELLIS_START_TIMEOUT", "900")) # Optional worker APIs. The clean Docker baseline deliberately ships only # text/multimodal chat; absent workers must fail explicitly instead of trying @@ -429,6 +431,11 @@ def _wait_music_ready() -> None: f"ACE-Step nach {MUSIC_START_TIMEOUT:.0f} s nicht bereit") +def _wait_yue2_ready() -> None: + _wait_aux_voice_ready("yue2_worker", "yue2_health", + "YuE2", YUE2_START_TIMEOUT) + + def _wait_separator_ready() -> None: deadline = time.monotonic() + SEPARATOR_START_TIMEOUT while time.monotonic() < deadline: @@ -490,9 +497,17 @@ def _wait_applio_ready() -> None: "Applio", APPLIO_START_TIMEOUT) +def _wait_trellis_ready() -> None: + _wait_aux_voice_ready("trellis_worker", "trellis_health", + "TRELLIS.2", TRELLIS_START_TIMEOUT) + + def _special_worker(mode: str) -> tuple[str, str, callable]: if mode == "music": return "/workers/music/start", _music_worker_state(), _wait_music_ready + if mode == "yue2": + return ("/workers/yue2/start", _worker_field("yue2_worker"), + _wait_yue2_ready) if mode == "separation": return "/workers/separator/start", _separator_worker_state(), _wait_separator_ready if mode == "voice": @@ -503,6 +518,9 @@ def _special_worker(mode: str) -> tuple[str, str, callable]: if mode == "applio": return ("/workers/applio/start", _worker_field("applio_worker"), _wait_applio_ready) + if mode == "trellis": + return ("/workers/trellis/start", _worker_field("trellis_worker"), + _wait_trellis_ready) raise ValueError(f"unbekannter Spezialmodus: {mode}") @@ -510,7 +528,8 @@ def set_operating_mode(mode: str) -> dict: """Atomarer Wechsel zwischen LLM und den exklusiven GPU-Werkzeugen.""" if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL: raise RuntimeError("Musikmodus ist nicht konfiguriert") - special_modes = {"music", "separation", "voice", "voicechange", "applio"} + special_modes = {"music", "yue2", "separation", "voice", "voicechange", + "applio", "trellis"} if mode not in {"llm", *special_modes}: raise ValueError("unbekannter Betriebsmodus") with STATE.lock: @@ -555,10 +574,12 @@ def set_operating_mode(mode: str) -> dict: _set_qwen_unavailable(True) try: _profile_controller_request("POST", "/workers/music/stop") + _profile_controller_request("POST", "/workers/yue2/stop") _profile_controller_request("POST", "/workers/separator/stop") _profile_controller_request("POST", "/workers/voice/stop") _profile_controller_request("POST", "/workers/voice-change/stop") _profile_controller_request("POST", "/workers/applio/stop") + _profile_controller_request("POST", "/workers/trellis/stop") _restore_qwen(profile) STATE.mode = "llm" STATE.mode_phase = "ready" @@ -577,7 +598,8 @@ def schedule_operating_mode(mode: str) -> tuple[bool, str]: """Start a transition in the background so chat/UI acknowledgement is instant.""" if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL: raise RuntimeError("Musikmodus ist nicht konfiguriert") - if mode not in {"llm", "music", "separation", "voice", "voicechange", "applio"}: + if mode not in {"llm", "music", "yue2", "separation", "voice", + "voicechange", "applio", "trellis"}: raise ValueError("unbekannter Betriebsmodus") with STATE.lock: if STATE.mode_phase not in {"ready", "error"}: @@ -612,11 +634,13 @@ def _control_command(data: dict, path: str) -> str | None: if not isinstance(text, str): return None command = text.strip().casefold() - return command if command in {"/athena music", "/athena stems", + return command if command in {"/athena music", "/athena yue2", + "/athena stems", "/athena separation", "/athena llm", "/athena voice", "/athena voicechange", "/athena changer", "/athena applio", + "/athena 3d", "/athena trellis", "/athena status"} else None @@ -2090,6 +2114,8 @@ class Handler(BaseHTTPRequestHandler): "phase": STATE.mode_phase, "music_worker": _music_worker_state(), "music_health": _music_worker_health(), + "yue2_worker": _worker_field("yue2_worker"), + "yue2_health": _worker_field("yue2_health"), "separator_worker": _separator_worker_state(), "separator_health": _separator_worker_health(), "voice_worker": _voice_worker_state(), @@ -2098,6 +2124,8 @@ class Handler(BaseHTTPRequestHandler): "voice_change_health": _voice_change_worker_health(), "applio_worker": _worker_field("applio_worker"), "applio_health": _worker_field("applio_health"), + "trellis_worker": _worker_field("trellis_worker"), + "trellis_health": _worker_field("trellis_health"), "return_profile": state.get("return_profile"), "last_error": STATE.mode_error, "enabled": ENABLE_MUSIC_MODE, @@ -2107,7 +2135,8 @@ class Handler(BaseHTTPRequestHandler): try: data = json.loads(self._read_body() or b"{}") mode = data.get("mode") if isinstance(data, dict) else None - if mode not in {"llm", "music", "separation", "voice", "voicechange", "applio"}: + if mode not in {"llm", "music", "yue2", "separation", "voice", + "voicechange", "applio", "trellis"}: raise ValueError("Feld 'mode' enthält einen unbekannten Betriebsmodus") started, phase = schedule_operating_mode(mode) self._send_json(202 if started else 200, { @@ -2770,22 +2799,28 @@ class Handler(BaseHTTPRequestHandler): profile = current_profile() text = (f"Athena läuft im {mode['active'].upper()}-Modus. " f"Phase: {mode['phase']}. Musik-Worker: " - f"{mode['music_worker']}. Stem-Separator: " + f"{mode['music_worker']}. YuE2: " + f"{mode['yue2_worker']}. Stem-Separator: " f"{mode['separator_worker']}. Voice Studio: " f"{mode['voice_worker']}. Voice Changer: " - f"{mode['voice_change_worker']}. LLM-Profil: {profile or 'entladen'}.") + f"{mode['voice_change_worker']}. 3D Studio: " + f"{mode['trellis_worker']}. LLM-Profil: {profile or 'entladen'}.") else: target = ("music" if command == "/athena music" else + "yue2" if command == "/athena yue2" else "separation" if command in {"/athena stems", "/athena separation"} else "voice" if command == "/athena voice" else "voicechange" if command in {"/athena voicechange", "/athena changer"} else "applio" if command == "/athena applio" + else "trellis" if command in {"/athena 3d", "/athena trellis"} else "llm") try: started, phase = schedule_operating_mode(target) if started: text = ("Musikstudio wird gestartet. LLM und TTS werden entladen." if target == "music" else + "YuE2 Studio wird gestartet. LLM und TTS werden entladen." + if target == "yue2" else "Stimmtrennung wird gestartet. LLM und TTS werden entladen." if target == "separation" else "Voice Studio wird gestartet. LLM und TTS werden entladen." @@ -2794,6 +2829,8 @@ class Handler(BaseHTTPRequestHandler): if target == "voicechange" else "Applio wird gestartet. LLM und TTS werden entladen." if target == "applio" else + "3D Studio wird gestartet. LLM und TTS werden entladen." + if target == "trellis" else "Spezialmodus wird beendet und das vorherige LLM-Profil wiederhergestellt.") else: text = (f"Athena ist bereits im {target.upper()}-Modus " @@ -3086,7 +3123,9 @@ def _startup_reconcile() -> None: log.info("Startup-Retention: %d alte Bilder entfernt", len(removed)) special_mode = previous.get("mode") - if ENABLE_MUSIC_MODE and special_mode in {"music", "separation", "voice", "voicechange"}: + if ENABLE_MUSIC_MODE and special_mode in {"music", "yue2", "separation", + "voice", "voicechange", "applio", + "trellis"}: STATE.mode = special_mode STATE.mode_phase = f"starting-{special_mode}" _set_qwen_unavailable(True)