Add managed LadyPoly WebUI using Deck music API

This commit is contained in:
Mikei386
2026-09-30 22:32:17 +02:00
parent 3aa2301a53
commit cf3e354434
13 changed files with 218 additions and 10 deletions
+15 -7
View File
@@ -20,6 +20,8 @@ def config_files(source,destination):
p=Path(source)
if destination.startswith('/run/secrets/'):
return {'file':blob(p)} if p.exists() else {}
if destination=='/data' and p.is_dir():
return {name:blob(p/name) for name in ('settings.json','deck-connection.json') if (p/name).is_file()}
if destination.endswith('/Data') and p.is_dir():
files={}
for name in ('Settings.fds','Backends.fds'):
@@ -43,7 +45,7 @@ def export(manager):
for row in manager.inventory():
x=json.loads(command(['inspect',row['id']]))[0];c=x['Config'];h=x['HostConfig'];image=json.loads(command(['image','inspect',x['Image']]))[0]
registry=next((d for d in image.get('RepoDigests',[]) if '/' in d.split('@')[0] and ('.' in d.split('/')[0] or ':' in d.split('/')[0])),None)
if not registry and row['name']!='athena-swarm-ui':registry=next(iter(image.get('RepoDigests',[])),None)
if not registry and row['name'] not in ('athena-swarm-ui','athena-ladypoly'):registry=next(iter(image.get('RepoDigests',[])),None)
mounts=[]
for i,m in enumerate(x['Mounts']):
source=m['Source'];relative=None
@@ -51,7 +53,7 @@ def export(manager):
try:relative=str(Path(source).relative_to(deck))
except ValueError:pass
mounts.append(dict(index=i,target=m['Destination'],read_only=not m['RW'],deck_path=relative,files=config_files(source,m['Destination']),was_file=Path(source).is_file()))
result.append(dict(name=row['name'],image=c['Image'],registry=registry,application=c.get('Labels',{}).get(APP,''),labels={k:v for k,v in (c.get('Labels') or {}).items() if k.startswith('io.athena-deck.')},env=c.get('Env') or [],entrypoint=c.get('Entrypoint'),cmd=c.get('Cmd'),uid=app_uid(row['id'],c.get('User') or ''),user=c.get('User') or '',workdir=c.get('WorkingDir') or '',network=h['NetworkMode'],ports=h.get('PortBindings') or {},restart=h.get('RestartPolicy',{}).get('Name') or 'no',memory=h.get('Memory') or 0,nanocpus=h.get('NanoCpus') or 0,running=x['State']['Running'],mounts=mounts,build_recipe='swarm-ui' if row['name']=='athena-swarm-ui' else None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd'))))
result.append(dict(name=row['name'],image=c['Image'],registry=registry,application=c.get('Labels',{}).get(APP,''),labels={k:v for k,v in (c.get('Labels') or {}).items() if k.startswith('io.athena-deck.')},env=c.get('Env') or [],entrypoint=c.get('Entrypoint'),cmd=c.get('Cmd'),uid=app_uid(row['id'],c.get('User') or ''),user=c.get('User') or '',workdir=c.get('WorkingDir') or '',network=h['NetworkMode'],ports=h.get('PortBindings') or {},restart=h.get('RestartPolicy',{}).get('Name') or 'no',memory=h.get('Memory') or 0,nanocpus=h.get('NanoCpus') or 0,running=x['State']['Running'],mounts=mounts,build_recipe='swarm-ui' if row['name']=='athena-swarm-ui' else 'ladypoly' if row['name']=='athena-ladypoly' else None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd'))))
return {'services':result,'configured':bool(deck)}
def validate(c):
@@ -59,8 +61,9 @@ def validate(c):
if any(c.get('labels',{}).get(k)!=v for k,v in LABELS.items()):raise ValueError('Nur ausdrücklich Deck zugeordnete Anwendungscontainer erlaubt.')
if c.get('unsupported'):raise ValueError('GPU-/privilegierter Container benötigt manuelle Einrichtung.')
if c.get('network') not in ('bridge','default','host'):raise ValueError('Benutzerdefiniertes Docker-Netzwerk benötigt manuelle Einrichtung.')
if c.get('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.')
if c.get('build_recipe') not in (None,'swarm-ui') or c.get('build_recipe')=='swarm-ui' and (c['name']!='athena-swarm-ui' or c.get('image')!='athena-swarm-ui:de7b834'):raise ValueError('Unbekanntes Build-Rezept.')
if c.get('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb','athena-ladypoly'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.')
recipes={'swarm-ui':('athena-swarm-ui','athena-swarm-ui:de7b834'),'ladypoly':('athena-ladypoly','athena-ladypoly:3960afc-deck1')}
if c.get('build_recipe') is not None and recipes.get(c['build_recipe'])!=(c['name'],c.get('image')):raise ValueError('Unbekanntes Build-Rezept.')
ref=c.get('registry') or c.get('image','')
if not isinstance(ref,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/@:-]{0,300}',ref):raise ValueError('Ungültige Image-Referenz.')
if c.get('restart') not in ('no','always','unless-stopped','on-failure'):raise ValueError('Ungültige Neustartregel.')
@@ -93,7 +96,9 @@ def validate(c):
native_media=rel in media and c['name']=='ltx-deskweb' and (target,m['read_only'])==media[rel] and not m['was_file']
if rel is not None and not native_media and (rel not in ('models','video/comfy-work/models','video/comfy-client-token') or not m['read_only']):raise ValueError('Deck-Volume nicht freigegeben; nur bekannte Modell-/Tokenpfade und LTX-Medienordner erlaubt.')
for name,data in m.get('files',{}).items():
if name not in ('file','Settings.fds','Backends.fds'):raise ValueError('Unbekannte Konfigurationsdatei.')
allowed=('file','Settings.fds','Backends.fds')
if c['name']=='athena-ladypoly' and m['target']=='/data':allowed+=('settings.json','deck-connection.json')
if name not in allowed:raise ValueError('Unbekannte Konfigurationsdatei.')
if not isinstance(data,str) or len(base64.b64decode(data,validate=True))>MAX_FILE:raise ValueError('Ungültige Konfigurationsdatei.')
return c
@@ -110,8 +115,10 @@ def restore(manager,c):
# Never replace or restart an existing service during a restore.
if x['Config']['Image'] not in (c['image'],c.get('registry')):raise ValueError('Vorhandener Container verwendet ein anderes Image; manuell prüfen.')
return {'state':'reused','message':'Vorhandener markierter Container unverändert übernommen.'}
if c.get('build_recipe')=='swarm-ui':
source=Path(__file__).parent/'swarm-ui'
if c['name']=='athena-ladypoly':
with socket.socket() as probe:probe.bind(('127.0.0.1',8127))
if c.get('build_recipe') in ('swarm-ui','ladypoly'):
source=Path(__file__).parent/c['build_recipe']
if not (source/'Dockerfile').is_file():raise ValueError('Gepinntes Swarm-Build-Rezept fehlt im Systemhelfer.')
command(['build','-t',c['image'],str(source)],1800)
else:
@@ -135,6 +142,7 @@ def restore(manager,c):
if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.')
base.mkdir(parents=True,exist_ok=True,mode=0o700)
args=['create','--name',name,'--network',c['network'],'--restart',c['restart'],'--cap-drop','ALL','--security-opt','no-new-privileges:true','--pids-limit','256']
if c['name']=='athena-ladypoly':args+=['--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size=128m,uid=65534,gid=65534']
if c['memory']:args+=['--memory',str(c['memory'])]
if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)]
for k,v in c['labels'].items():
+1 -1
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['music.py','music-ui.js','audio_cpp_runtime.py','audio-cpp-ui.js','ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock']
FILES = ['deploy/ladypoly/Dockerfile','deploy/ladypoly/bridge.py','music.py','music-ui.js','audio_cpp_runtime.py','audio-cpp-ui.js','ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock']
def run(*args, check=True, interactive=False):
+14
View File
@@ -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"]
+52
View File
@@ -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.
+73
View File
@@ -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
+15
View File
@@ -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')
+30
View File
@@ -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()
+3
View File
@@ -25,6 +25,9 @@ def main():
(base/'swarm-ui').mkdir(exist_ok=True)
for name in ('Dockerfile','patch-source.py','proxy.py','entrypoint.py'):
shutil.copyfile(Path(__file__).parent/'swarm-ui'/name,base/'swarm-ui'/name)
(base/'ladypoly').mkdir(exist_ok=True)
for name in ('Dockerfile','bridge.py'):
shutil.copyfile(Path(__file__).parent/'ladypoly'/name,base/'ladypoly'/name)
if args.deck_state:
registered=Path(args.deck_state).resolve()
if not registered.is_dir():raise SystemExit('Deck-Zustandsverzeichnis fehlt.')