From f8b1b19d4a6e7d39c62d6151f14f7a6c054dbf1b Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:49:16 +0200
Subject: [PATCH] Add isolated YuE2 playground
---
docs/TESTED_MODELS.md | 2 +-
experiments/yue2-3b/Dockerfile | 2 +
experiments/yue2-3b/README.md | 13 ++
experiments/yue2-3b/compose.yaml | 34 ++++
experiments/yue2-3b/ui/index.html | 45 ++++++
experiments/yue2-3b/ui/server.py | 255 ++++++++++++++++++++++++++++++
6 files changed, 350 insertions(+), 1 deletion(-)
create mode 100644 experiments/yue2-3b/ui/index.html
create mode 100644 experiments/yue2-3b/ui/server.py
diff --git a/docs/TESTED_MODELS.md b/docs/TESTED_MODELS.md
index e2c4148..9783cc6 100644
--- a/docs/TESTED_MODELS.md
+++ b/docs/TESTED_MODELS.md
@@ -76,7 +76,7 @@ Titelgenerierung und Kontextkompression in Hermes.
|---|---|---|---|---|---|
| 08.–10.09.2026 | `ACE-Step/acestep-v15-xl-sft` mit `acestep-5Hz-lm-1.7B`, offizielles ACE-Step-1.5-Image `sha256:95652cd780c78a1b1a7f6f0335530430f0ae53d96c7c12d59f9f39fa23d38567` | Mehrere Instrumentaltests bis 244 s; abschließender Kontrolllauf mit geladenem 1.7B-Planer, `thinking=True`, XL-SFT 4B, 80 Schritten, Guidance 8 und Shift 3 | technisch vollständig und schnell, aber wiederholt nur Geräusche/Krach oder musikalisch chaotische Ergebnisse; der letzte Lauf schließt einen bloß fehlenden Planer als Ursache aus | **qualitativ verworfen**; nicht als Qualitätslösung weiterverfolgen | Athena: `/data/music/acestep/`; [SPECIALIZED_MODEL_ROADMAP.md](SPECIALIZED_MODEL_ROADMAP.md) |
| 10.09.2026 | `HeartMuLa/HeartMuLa-oss-3B-happy-new-year` mit `HeartMuLa/HeartCodec-oss-20260123`, heartlib `3783bdb8441f2c298b1e64c8651173aac200361c` | Mehrere Instrumentalversuche mit offiziellen Samplingwerten; HeartMuLa BF16 auf RTX 5080, HeartCodec FP32 auf RTX 3060 | technisch stabil und schnell, klanglich jedoch fast so unbrauchbar wie ACE-Step: Fantasiesprache trotz Instrumentalwunsch und gravierende Missachtung der Synthwave-/Synthpop-Stilvorgabe. Die lokale Test-UI hatte zusätzlich einen nicht upstream dokumentierten `[Instrumental]`-Marker verwendet | **qualitativ verworfen und vollständig entfernt**; Container, Image, Gewichte und Ausgaben am 10.09.2026 gelöscht | diese Tabelle; keine Laufzeitreste auf Athena |
-| 10.09.2026 | `m-a-p/YuE2-3B` mit `m-a-p/YuE2-Vae`, offizieller Release `yue2-v0.1.6` | Isolierter Kontrolltest vorbereitet: leere Lyrics, expliziter Instrumentalstil und vollständige symbolische Melodie-/Akkordplanung | Noch nicht gestartet. Gewichte und VAE sind vollständig geladen und anhand der veröffentlichten SHA-256-Manifeste geprüft; das gepinnte Test-Image und die manuelle Startdefinition sind fertig. Kein Container und keine GPU-Last durch YuE2 | **startbereit / Hörtest ausstehend**; Start nur nach ausdrücklicher Freigabe, keine Router- oder Dashboard-Integration | [yue2-3b](../experiments/yue2-3b/README.md) |
+| 10.09.2026 | `m-a-p/YuE2-3B` mit `m-a-p/YuE2-Vae`, offizieller Release `yue2-v0.1.6` | Unquantisiertes BF16 ausschließlich auf RTX 5080; leere Lyrics, expliziter Instrumentalstil, 118 BPM, Seed 831001 und vollständige symbolische Planung | 189,5 s Musik in 83,8 s erzeugt; 48 kHz, Stereo, 24-Bit-FLAC, keine Kürzung und kein OOM. Der erste Hörtest war im deutlichen Gegensatz zu ACE-Step und HeartMuLa musikalisch überzeugend. Entstehung über neuen ABC-Plan, 4.739 semantische Tokens und neue akustische Latents verifiziert; keine mitgelieferte Demo-Datei | **technischer Test und erste Hörabnahme bestanden**; isolierter Playground bereit, breitere Stil-/Gesangsprüfung und spätere Routerentscheidung noch offen | [yue2-3b](../experiments/yue2-3b/README.md); Athena: `/data/music/yue2/instrumental_synthwave_control/` |
## Audio-Trennung
diff --git a/experiments/yue2-3b/Dockerfile b/experiments/yue2-3b/Dockerfile
index e360037..e0e170f 100644
--- a/experiments/yue2-3b/Dockerfile
+++ b/experiments/yue2-3b/Dockerfile
@@ -10,6 +10,8 @@ RUN git clone https://github.com/multimodal-art-projection/YuE.git /opt/yue2 \
&& git -C /opt/yue2 checkout "${YUE2_COMMIT}" \
&& python -m pip install --no-cache-dir /opt/yue2
+COPY ui /opt/yue2-playground
+
WORKDIR /workspace
ENV PYTHONUNBUFFERED=1 \
YUE2_KIT=/workspace
diff --git a/experiments/yue2-3b/README.md b/experiments/yue2-3b/README.md
index a22cb23..6eeb728 100644
--- a/experiments/yue2-3b/README.md
+++ b/experiments/yue2-3b/README.md
@@ -30,6 +30,19 @@ image is built, but no YuE2 container has been created or started.
The initial control request is a true empty-lyrics instrumental request. No
invented `[Instrumental]` lyrics marker is used.
+## Temporary playground
+
+The optional `playground` profile provides a small German web interface for
+new-song generation. It lists persistent results, streams FLAC files with HTTP
+range support and permits explicit deletion. It is bound only to Athena's
+localhost on port 8014 and is not integrated into the profile router yet. The
+web process itself does not load the model; a click on **Song generieren**
+starts one exclusive offline generation job.
+
+```sh
+docker compose --profile playground up -d yue2-ui
+```
+
## Manual test (only after GPU availability was checked)
From `/opt/mike-ai/yue2-3b` on Athena:
diff --git a/experiments/yue2-3b/compose.yaml b/experiments/yue2-3b/compose.yaml
index 1a47265..d3d0a04 100644
--- a/experiments/yue2-3b/compose.yaml
+++ b/experiments/yue2-3b/compose.yaml
@@ -21,3 +21,37 @@ services:
- driver: nvidia
device_ids: ["GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"]
capabilities: [gpu]
+
+ yue2-ui:
+ profiles: ["playground"]
+ build:
+ context: .
+ args:
+ YUE2_COMMIT: 9c6c4b349be978b06a9d0d958471a07a6cdeff4d
+ image: mike-ai/yue2:3b-0.1.6
+ container_name: mike-ai-yue2-playground
+ restart: "no"
+ entrypoint: ["python", "/opt/yue2-playground/server.py"]
+ environment:
+ NVIDIA_VISIBLE_DEVICES: "GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"
+ NVIDIA_DRIVER_CAPABILITIES: compute,utility
+ YUE2_UI_HOST: "0.0.0.0"
+ YUE2_UI_PORT: "8014"
+ ports:
+ - "127.0.0.1:8014:8014"
+ volumes:
+ - /data/models/yue2:/workspace/models:ro
+ - /data/music/yue2:/workspace/runs
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - 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/health', timeout=2)"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
diff --git a/experiments/yue2-3b/ui/index.html b/experiments/yue2-3b/ui/index.html
new file mode 100644
index 0000000..380de57
--- /dev/null
+++ b/experiments/yue2-3b/ui/index.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ YuE2 Playground
+
+
+
+ YuE2 Playground 🎵
Lokale Musikgenerierung auf Athena · 48 kHz Stereo · RTX 5080
+
+
diff --git a/experiments/yue2-3b/ui/server.py b/experiments/yue2-3b/ui/server.py
new file mode 100644
index 0000000..37b718a
--- /dev/null
+++ b/experiments/yue2-3b/ui/server.py
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import mimetypes
+import os
+import random
+import re
+import shutil
+import subprocess
+import threading
+import time
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import unquote, urlparse
+
+
+HOST = os.getenv("YUE2_UI_HOST", "0.0.0.0")
+PORT = int(os.getenv("YUE2_UI_PORT", "8014"))
+ROOT = Path("/workspace/runs")
+REQUESTS = ROOT / ".playground_requests"
+LOGS = ROOT / ".playground_logs"
+INDEX = Path(__file__).with_name("index.html")
+SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
+LOCK = threading.Lock()
+JOBS: dict[str, dict] = {}
+ACTIVE: str | None = None
+
+
+def now_ms() -> int:
+ return int(time.time() * 1000)
+
+
+def existing_jobs() -> list[dict]:
+ found: list[dict] = []
+ for result_file in ROOT.glob("*/result.json"):
+ try:
+ result = json.loads(result_file.read_text(encoding="utf-8"))
+ request_file = result_file.parent / "request.json"
+ request = json.loads(request_file.read_text(encoding="utf-8"))
+ found.append({
+ "id": result_file.parent.name,
+ "state": "complete",
+ "style": request.get("style", ""),
+ "seed": request.get("seed"),
+ "instrumental": not bool(request.get("lyrics")),
+ "audio_seconds": result.get("audio_seconds"),
+ "elapsed_seconds": (result.get("timing") or {}).get("e2e_seconds"),
+ "audio_url": f"/audio/{result_file.parent.name}",
+ "created": int(result_file.stat().st_mtime * 1000),
+ })
+ except (OSError, ValueError, TypeError):
+ continue
+ return sorted(found, key=lambda item: item["created"], reverse=True)
+
+
+def snapshot() -> dict:
+ with LOCK:
+ live = [dict(item) for item in JOBS.values()]
+ active = ACTIVE
+ known = {item["id"] for item in live}
+ live.extend(item for item in existing_jobs() if item["id"] not in known)
+ return {"active": active, "jobs": sorted(live, key=lambda item: item["created"], reverse=True)}
+
+
+def run_job(job_id: str, request: dict) -> None:
+ global ACTIVE
+ output = ROOT / job_id
+ request_file = REQUESTS / f"{job_id}.json"
+ log_file = LOGS / f"{job_id}.log"
+ command = [
+ "yue2", "generate", "--offline", "--device", "cuda:0", "--budget", "16",
+ "--request", str(request_file), "--output", str(output),
+ ]
+ started = time.monotonic()
+ try:
+ with log_file.open("w", encoding="utf-8") as log:
+ process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, text=True)
+ with LOCK:
+ JOBS[job_id]["pid"] = process.pid
+ code = process.wait()
+ if code != 0:
+ raise RuntimeError(f"YuE2 wurde mit Exit-Code {code} beendet")
+ result = json.loads((output / "result.json").read_text(encoding="utf-8"))
+ update = {
+ "state": "complete",
+ "audio_seconds": result.get("audio_seconds"),
+ "elapsed_seconds": round(time.monotonic() - started, 1),
+ "audio_url": f"/audio/{job_id}",
+ }
+ except Exception as exc:
+ tail = ""
+ try:
+ tail = "\n".join(log_file.read_text(encoding="utf-8", errors="replace").splitlines()[-30:])
+ except OSError:
+ pass
+ update = {"state": "failed", "error": str(exc), "log": tail,
+ "elapsed_seconds": round(time.monotonic() - started, 1)}
+ with LOCK:
+ JOBS[job_id].update(update)
+ ACTIVE = None
+
+
+class Handler(BaseHTTPRequestHandler):
+ server_version = "YuE2Playground/1.0"
+
+ def log_message(self, fmt: str, *args: object) -> None:
+ print(f"{self.address_string()} - {fmt % args}", flush=True)
+
+ def json_response(self, status: int, payload: object) -> None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self) -> None: # noqa: N802
+ path = urlparse(self.path).path
+ if path == "/health":
+ self.json_response(200, {"status": "ok", "active": ACTIVE})
+ return
+ if path == "/api/jobs":
+ self.json_response(200, snapshot())
+ return
+ if path.startswith("/audio/"):
+ self.send_audio(unquote(path.removeprefix("/audio/")))
+ return
+ if path in {"/", "/index.html"}:
+ body = INDEX.read_bytes()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+ self.send_error(404)
+
+ def do_POST(self) -> None: # noqa: N802
+ global ACTIVE
+ if urlparse(self.path).path != "/api/jobs":
+ self.send_error(404)
+ return
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ if length <= 0 or length > 65536:
+ raise ValueError("Ungültige Anfragegröße")
+ data = json.loads(self.rfile.read(length))
+ style = str(data.get("style", "")).strip()
+ lyrics = str(data.get("lyrics", "")).strip()
+ instrumental = bool(data.get("instrumental", False))
+ cot = str(data.get("cot", "full"))
+ if not style or len(style) > 3000:
+ raise ValueError("Bitte eine Stilbeschreibung mit höchstens 3000 Zeichen eingeben")
+ if len(lyrics) > 20000:
+ raise ValueError("Der Liedtext ist zu lang")
+ if cot not in {"full", "off"}:
+ raise ValueError("Unbekannter Planungsmodus")
+ if not instrumental and not lyrics:
+ raise ValueError("Für einen Song mit Gesang fehlt der Liedtext")
+ if instrumental:
+ lyrics = ""
+ if "instrumental" not in style.casefold():
+ style = "Instrumental, no vocals, " + style
+ raw_seed = data.get("seed")
+ seed = int(raw_seed) if str(raw_seed).strip() else random.SystemRandom().randrange(1, 2**31)
+ if not 0 <= seed < 2**32:
+ raise ValueError("Seed muss zwischen 0 und 4294967295 liegen")
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
+ self.json_response(400, {"error": str(exc)})
+ return
+
+ with LOCK:
+ if ACTIVE is not None:
+ self.json_response(409, {"error": f"Auftrag {ACTIVE} läuft bereits"})
+ return
+ job_id = time.strftime("song-%Y%m%d-%H%M%S") + f"-{seed % 10000:04d}"
+ request = {"id": job_id, "style": style, "lyrics": lyrics, "cot": cot, "seed": seed}
+ REQUESTS.mkdir(parents=True, exist_ok=True)
+ LOGS.mkdir(parents=True, exist_ok=True)
+ request_file = REQUESTS / f"{job_id}.json"
+ request_file.write_text(json.dumps(request, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ JOBS[job_id] = {"id": job_id, "state": "running", "style": style,
+ "seed": seed, "instrumental": instrumental,
+ "created": now_ms(), "elapsed_seconds": 0}
+ ACTIVE = job_id
+ threading.Thread(target=run_job, args=(job_id, request), daemon=True).start()
+ self.json_response(HTTPStatus.ACCEPTED, JOBS[job_id])
+
+ def do_DELETE(self) -> None: # noqa: N802
+ path = urlparse(self.path).path
+ job_id = unquote(path.removeprefix("/api/jobs/"))
+ if not path.startswith("/api/jobs/") or not SAFE_ID.fullmatch(job_id):
+ self.send_error(404)
+ return
+ with LOCK:
+ if ACTIVE == job_id:
+ self.json_response(409, {"error": "Ein laufender Auftrag kann nicht gelöscht werden"})
+ return
+ JOBS.pop(job_id, None)
+ shutil.rmtree(ROOT / job_id, ignore_errors=True)
+ for directory, suffix in ((REQUESTS, ".json"), (LOGS, ".log")):
+ try:
+ (directory / f"{job_id}{suffix}").unlink()
+ except FileNotFoundError:
+ pass
+ self.json_response(200, {"status": "deleted", "id": job_id})
+
+ def send_audio(self, job_id: str) -> None:
+ if not SAFE_ID.fullmatch(job_id):
+ self.send_error(404)
+ return
+ path = ROOT / job_id / "audio.flac"
+ if not path.is_file():
+ self.send_error(404)
+ return
+ size = path.stat().st_size
+ start, end = 0, size - 1
+ status = 200
+ range_header = self.headers.get("Range", "")
+ if range_header.startswith("bytes="):
+ try:
+ left, right = range_header[6:].split("-", 1)
+ start = int(left) if left else 0
+ end = min(int(right), size - 1) if right else size - 1
+ if start < 0 or start > end:
+ raise ValueError
+ status = 206
+ except ValueError:
+ self.send_error(416)
+ return
+ self.send_response(status)
+ self.send_header("Content-Type", mimetypes.guess_type(path.name)[0] or "audio/flac")
+ self.send_header("Accept-Ranges", "bytes")
+ self.send_header("Content-Length", str(end - start + 1))
+ if status == 206:
+ self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
+ self.end_headers()
+ with path.open("rb") as source:
+ source.seek(start)
+ remaining = end - start + 1
+ while remaining:
+ chunk = source.read(min(1024 * 1024, remaining))
+ if not chunk:
+ break
+ self.wfile.write(chunk)
+ remaining -= len(chunk)
+
+
+if __name__ == "__main__":
+ ROOT.mkdir(parents=True, exist_ok=True)
+ print(f"YuE2 Playground listening on {HOST}:{PORT}", flush=True)
+ ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()