Add managed LadyPoly WebUI using Deck music API
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
ARG UPSTREAM_COMMIT=3960afc309efcef5f4f570209cf01160b96b1ea3
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git libsndfile1 && rm -rf /var/lib/apt/lists/* \
|
||||
&& git init /app && cd /app && git remote add origin https://github.com/Ladypoly/YuE2_WebUI.git \
|
||||
&& git fetch --depth 1 origin ${UPSTREAM_COMMIT} && git checkout --detach FETCH_HEAD \
|
||||
&& rm -rf /app/.git \
|
||||
&& pip install --no-cache-dir fastapi==0.115.12 uvicorn==0.34.2 python-multipart==0.0.20 numpy==2.2.6 soundfile==0.13.1
|
||||
COPY bridge.py /app/webui/deck_bridge.py
|
||||
RUN python -c "from pathlib import Path; p=Path('/app/webui/server.py'); s=p.read_text(); s=s.replace('SETTINGS_FILE = Path(__file__).resolve().parent / \"settings.json\"','SETTINGS_FILE = Path(\"/data/settings.json\")'); s=s.replace('from loras import Loras', 'from deck_bridge import Loras'); s=s.replace('if __name__ == \"__main__\":','from deck_bridge import install\ninstall(globals())\n\nif __name__ == \"__main__\":'); p.write_text(s)"
|
||||
RUN python -c "from pathlib import Path; p=Path('/app/webui/static/index.html'); s=p.read_text().replace('<body>', '<body><div style=\"padding:12px;background:#193249;color:white\">LadyPoly mit Athena Deck · <a style=\"color:#8de8ff\" href=\"/deck-connection\">Deck-Verbindung einrichten</a> · In Deck Musik aktivieren. Worker-Abbruch in Deck.</div>'); s=s.replace('value=\"full\" checked', 'value=\"full\" disabled').replace('value=\"melody\"', 'value=\"melody\" disabled').replace('value=\"off\"', 'value=\"off\" checked'); s=s.replace('</head>', '<style>#engineToggle,#muse,#coverDrawer,#scoreDrawer,#advDrawer,#cancelRun{display:none!important}</style></head>'); p.write_text(s)"
|
||||
ENV PYTHONPATH=/app/src YUE2_HOST=127.0.0.1 YUE2_PORT=8127 YUE2_OUTPUTS=/data/outputs HOME=/data
|
||||
WORKDIR /app
|
||||
USER 65534:65534
|
||||
CMD ["python", "webui/server.py"]
|
||||
@@ -0,0 +1,52 @@
|
||||
# LadyPoly YuE2 für Athena Deck
|
||||
|
||||
Die gepinnte LadyPoly-WebUI (Commit 3960afc309efcef5f4f570209cf01160b96b1ea3)
|
||||
läuft separat ohne Torch, CUDA, Modellmounts oder Docker-Socket. Die schmale
|
||||
Anbindung nutzt Decks authentifizierte Musik-API auf 127.0.0.1:8120.
|
||||
Deck steuert audio.cpp, Gewichte und den exklusiven Musikmodus.
|
||||
|
||||
Auf Debian mit vorhandenem Docker, im Athena-Deck-Repository:
|
||||
|
||||
```sh
|
||||
docker build -t athena-ladypoly:3960afc-deck1 deploy/ladypoly
|
||||
sudo python3 deploy/ladypoly/install.py
|
||||
```
|
||||
|
||||
Container: `athena-ladypoly`. Frei zu prüfender Port: **8127**, nur Host-Loopback.
|
||||
Host-Netzwerk verbindet zur lokalen Deck-API; keine öffentliche Portfreigabe.
|
||||
Labels: `io.athena-deck.managed=true`, `io.athena-deck.role=application`,
|
||||
`io.athena-deck.application=ladypoly`. Diese machen ihn unter Weitere Dienste sichtbar.
|
||||
Volume: `/opt/athena-ladypoly/data:/data:rw`, UID/GID 65534, Verzeichnis 0700.
|
||||
Enthält Verbindungsdaten/Token (0600), UI-Einstellungen und generierte Musik.
|
||||
Keine Modelle oder Runtime im Container. Image wird aus diesem Rezept gebaut;
|
||||
noch kein öffentliches Registry-Image/Pull-Befehl verfügbar.
|
||||
|
||||
```sh
|
||||
ssh -i ~/.ssh/athena_key -o BatchMode=yes -o ExitOnForwardFailure=yes -N -L 8127:127.0.0.1:8127 root@192.168.1.212
|
||||
```
|
||||
|
||||
Dann http://127.0.0.1:8127/deck-connection öffnen. Eigenen Deck-API-Token und
|
||||
exakten freigegebenen YuE2-Profilnamen eingeben. Die Verbindung wird gegen die
|
||||
Musik-Modellliste geprüft. Bei Tokenwechsel hier erneut speichern.
|
||||
In Deck auf Musik umschalten, anschließend in LadyPoly Stil und Liedtext
|
||||
angeben und generieren. Deck-Endpunkt muss gestartet sein.
|
||||
|
||||
Unterstützt: Titel/Bibliothek, Liedtext, Stil, Seed, WAV-Erzeugung über Deck,
|
||||
FLAC-Bibliothek und Wiedergabe. Derzeit Direct-Modus, bis 9000 semantische Tokens
|
||||
und sechs Syntheseschritte. Kein Versprechen einer bestimmten Songdauer.
|
||||
Writer, Cover-Erzeugung, Score, LoRA und Sampling-Overrides sind nicht angebunden;
|
||||
die betreffenden Bedienelemente sind ausgeblendet/gesperrt. Abbruch eines laufenden
|
||||
GPU-Jobs erfolgt in Deck; die WebUI ist kein zweiter Prozessmanager.
|
||||
|
||||
```sh
|
||||
docker stop athena-ladypoly
|
||||
docker start athena-ladypoly
|
||||
```
|
||||
|
||||
Der Deck-Backuphelfer kennt das gepinnte Build-Rezept und sichert die beiden
|
||||
Konfigurationsdateien im verschlüsselten Backup. Ergebnisse unter outputs sind
|
||||
Nutzdaten und werden nicht exportiert. Beim Restore wird das Image wieder gebaut;
|
||||
der Port muss frei sein. Deck-API-Portänderungen erfordern derzeit eine Anpassung
|
||||
von `BASE` in bridge.py und einen Image-Neubau.
|
||||
|
||||
Upstream-Lizenz und Hinweise verbleiben unter /app im Image.
|
||||
@@ -0,0 +1,73 @@
|
||||
"""LadyPoly UI -> authenticated Deck music API. No local model engine."""
|
||||
import json, os, time, urllib.request, urllib.error
|
||||
from pathlib import Path
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
CONNECTION = Path('/data/deck-connection.json')
|
||||
BASE = 'http://127.0.0.1:8120'
|
||||
|
||||
def request(path, data=None, connection=None):
|
||||
c = connection or (json.loads(CONNECTION.read_text()) if CONNECTION.exists() else {})
|
||||
if not c.get('token'): raise RuntimeError('Zuerst unter Deck-Verbindung den API-Token hinterlegen.')
|
||||
req = urllib.request.Request(BASE + path, data=json.dumps(data).encode() if data is not None else None,
|
||||
headers={'Authorization': 'Bearer ' + c['token'], 'Content-Type':'application/json'})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=1850) as response: return response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
try: message=json.loads(exc.read()).get('error', {}).get('message', 'Deck-Anfrage abgelehnt')
|
||||
except Exception: message='Deck-Anfrage abgelehnt'
|
||||
raise RuntimeError(message) from None
|
||||
|
||||
class Loras:
|
||||
def __init__(self, root):self.extra_dirs=[]
|
||||
def resolve(self, values):return [], []
|
||||
def status(self, values):return {'adapters':[], 'selected':[], 'missing':[], 'dirs':[]}
|
||||
def discover(self):return []
|
||||
|
||||
class Connection(BaseModel):
|
||||
token: str
|
||||
model: str
|
||||
|
||||
PAGE='''<!doctype html><meta charset="utf-8"><style>body{background:#12151c;color:#eee;font:18px system-ui;max-width:720px;margin:50px auto}input,button{padding:12px;display:block;margin:15px 0;width:90%}a{color:#72d6ff}</style><h1>LadyPoly · Deck-Verbindung</h1><p>In Deck das YuE2-Profil am API-Endpunkt freigeben und den Musik-Modus aktivieren. Hier den API-Token aus „Zugang & API“ und den genauen Profilnamen eingeben.</p><form id="f"><input id="token" type="password" placeholder="Deck API-Token" required autocomplete="off"><input id="model" placeholder="API-Profilname, z. B. YuE2 Studio" required><button>Verbindung prüfen und speichern</button></form><p id="msg"></p><a href="/">LadyPoly öffnen →</a><p>Diese Anbindung unterstützt Liedtext, Stil, Seed und Syntheseschritte. Score, LoRA, eigener Writer und Cover-Generator sind hier noch nicht angebunden. Deck lädt und entlädt die Gewichte.</p><script>f.onsubmit=async e=>{e.preventDefault();msg.textContent='Prüfe …';try{let r=await fetch('/api/deck/connection',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:token.value,model:model.value})});let d=await r.json();msg.textContent=r.ok?'Verbunden. Jetzt LadyPoly öffnen.':d.detail;token.value=''}catch(e){msg.textContent='Verbindung fehlgeschlagen'}}</script>'''
|
||||
|
||||
def install(g):
|
||||
app=g['app'];g['SETTINGS'].song_engine='gguf';g['SETTINGS'].art_auto=False;g['SETTINGS'].stall_timeout=0
|
||||
@app.get('/deck-connection', response_class=HTMLResponse)
|
||||
def connection_page():return PAGE
|
||||
@app.post('/api/deck/connection')
|
||||
def connection_save(body: Connection):
|
||||
c=body.model_dump()
|
||||
if not 1<=len(body.token)<=512:raise HTTPException(400,'Ungültiger Token.')
|
||||
try:
|
||||
models=json.loads(request('/v1/audio/music/models',connection=c))['data']
|
||||
if body.model not in [x['id'] for x in models]:raise RuntimeError('Profil nicht freigegeben oder nicht ausführbar. Bitte in Deck prüfen.')
|
||||
except Exception as e:raise HTTPException(400,str(e)) from None
|
||||
CONNECTION.write_text(json.dumps(c));CONNECTION.chmod(0o600)
|
||||
return {'ok':True}
|
||||
def render(job,spec):
|
||||
if spec.get('abc') or spec.get('cfg_scale') is not None or spec.get('abc_sampling') or spec.get('semantic_sampling') or g['SETTINGS'].loras:
|
||||
raise RuntimeError('Score, Sampling-Overrides und LoRA sind in der Deck-Anbindung noch nicht verfügbar.')
|
||||
c=json.loads(CONNECTION.read_text()) if CONNECTION.exists() else {}
|
||||
spec['cot']='off'
|
||||
job.setup='Deck lädt YuE2 und erzeugt Musik · Musik-Modus erforderlich';job.setup_at=time.time();job.push(force=True)
|
||||
start=time.monotonic()
|
||||
audio=request('/v1/audio/music',dict(model=c.get('model',''),lyrics=spec['lyrics'],style=spec['style'],seed=spec['seed'],steps=g['SETTINGS'].ode_steps,max_tokens=9000))
|
||||
if job.cancel_flag.is_set():raise InterruptedError('UI-Auftrag abgebrochen. Laufenden Deck-Job in Deck stoppen.')
|
||||
work=g['OUTPUTS']/('.pending-'+job.id);work.mkdir(parents=True,exist_ok=True)
|
||||
wav=work/'audio.wav';wav.write_bytes(audio)
|
||||
return g['GgufSong'](spec,wav,{'e2e_seconds':time.monotonic()-start},'',{'runtime':'Athena Deck audio.cpp','profile':c.get('model')})
|
||||
g['render_gguf']=render;g['render_torch']=render
|
||||
g['ENGINE'].detail='Remote-Laufzeit in Athena Deck · Musik-Modus erforderlich'
|
||||
@app.middleware('http')
|
||||
async def deck_guard(req,call_next):
|
||||
path=req.url.path
|
||||
origin=req.headers.get('Origin')
|
||||
if req.method!='GET' and origin and origin not in ('http://'+req.headers.get('Host',''),'https://'+req.headers.get('Host','')):
|
||||
return g['JSONResponse']({'detail':'Cross-Origin-Zugriff nicht erlaubt.'},status_code=403)
|
||||
if req.method!='GET' and not (path in {'/api/generate','/api/deck/connection','/api/unlock'} or (req.method=='DELETE' and path.startswith('/api/library/') and path.count('/')==3)):
|
||||
return g['JSONResponse']({'detail':'Laufzeiten, Modelle und Einstellungen werden in Athena Deck verwaltet.'},status_code=409)
|
||||
response=await call_next(req)
|
||||
response.headers['Cache-Control']='no-store'
|
||||
return response
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Run as root on Athena. Only creates the explicit optional LadyPoly UI."""
|
||||
import os, socket, subprocess
|
||||
from pathlib import Path
|
||||
base=Path('/opt/athena-ladypoly/data');name='athena-ladypoly';image='athena-ladypoly:3960afc-deck1'
|
||||
if subprocess.run(['docker','inspect',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:
|
||||
raise SystemExit('LadyPoly container already exists; no automatic replacement.')
|
||||
with socket.socket() as s:s.bind(('127.0.0.1',8127))
|
||||
base.mkdir(parents=True,exist_ok=True);os.chown(base,65534,65534);base.chmod(0o700)
|
||||
subprocess.check_call(['docker','run','-d','--name',name,'--network','host','--restart','unless-stopped','--read-only',
|
||||
'--cap-drop','ALL','--security-opt','no-new-privileges:true','--memory','1g','--cpus','2',
|
||||
'--tmpfs','/tmp:rw,noexec,nosuid,size=128m,uid=65534,gid=65534',
|
||||
'--label','io.athena-deck.managed=true','--label','io.athena-deck.role=application',
|
||||
'--label','io.athena-deck.application=ladypoly','--label','io.athena-deck.port=8127',
|
||||
'-v',str(base)+':/data:rw',image])
|
||||
print('LadyPoly UI: http://127.0.0.1:8127/deck-connection')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Synthetic bridge contract; no real API token or GPU job."""
|
||||
import io,json,tempfile,unittest,wave
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import server
|
||||
import deck_bridge as bridge
|
||||
|
||||
class BridgeTest(unittest.TestCase):
|
||||
def test_forward_and_audio_library(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root=Path(temp);connection=root/'connection.json';connection.write_text(json.dumps({'model':'Synthetic Music','token':'synthetic'}))
|
||||
raw=io.BytesIO()
|
||||
with wave.open(raw,'wb') as wav:wav.setnchannels(2);wav.setsampwidth(2);wav.setframerate(48000);wav.writeframes(b'\0'*192000)
|
||||
job=SimpleNamespace(id='synthetic',cancel_flag=SimpleNamespace(is_set=lambda:False),push=lambda **k:None)
|
||||
spec={'style':'synthetic piano','lyrics':'synthetic test','seed':123,'id':'synthetic','cot':'off'}
|
||||
with patch.object(bridge,'CONNECTION',connection),patch.object(server,'OUTPUTS',root),patch.object(bridge,'request',return_value=raw.getvalue()) as request:
|
||||
result=server.render_gguf(job,spec)
|
||||
payload=request.call_args.args[1]
|
||||
self.assertEqual(payload['model'],'Synthetic Music');self.assertEqual(payload['steps'],6)
|
||||
self.assertEqual(payload['max_tokens'],9000);self.assertNotIn('abc',payload)
|
||||
saved=result.save_artifacts(root/'take');self.assertEqual(saved['audio_seconds'],1)
|
||||
self.assertTrue((root/'take/audio.flac').is_file())
|
||||
def test_unsupported_score_does_not_start_deck(self):
|
||||
with patch.object(bridge,'request') as request:
|
||||
with self.assertRaisesRegex(RuntimeError,'Score'):
|
||||
server.render_gguf(None,{'abc':'synthetic'})
|
||||
request.assert_not_called()
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in New Issue
Block a user