From b731811482c1e9e5794e2c3955fd718173099a56 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Wed, 30 Sep 2026 21:21:04 +0200
Subject: [PATCH] Reuse component downloads and assignments for YuE2 music
profiles
---
docs/RUNTIME_DISCOVERY.md | 4 ++++
profiles-ui.js | 4 ++--
profiles.py | 24 ++++++++++++++++++++----
server.py | 1 +
test_music_components.py | 26 ++++++++++++++++++++++++++
5 files changed, 53 insertions(+), 6 deletions(-)
create mode 100644 test_music_components.py
diff --git a/docs/RUNTIME_DISCOVERY.md b/docs/RUNTIME_DISCOVERY.md
index 36bf63f..e7df274 100644
--- a/docs/RUNTIME_DISCOVERY.md
+++ b/docs/RUNTIME_DISCOVERY.md
@@ -15,3 +15,7 @@ Eine vorhandene Laufzeit wird wiederverwendet. Ein fehlender Worker oder Workflo
Installation liegt im Deck-Zustandsverzeichnis unter `audio-cpp-runtime`. Status, Abbruch und eigene Build-Ausgabe sind in der GUI verfügbar. Der CLI-Start wird ohne Modell geprüft. Dieser Build lädt **keine Gewichte**. Der ausführbare Deck-Musikworker und die Ladypoly-WebUI-Anbindung fehlen noch; sie sind separat umzusetzen. Auch andere audio.cpp-Familien brauchen geprüfte modellspezifische Anbindungen.
Interne Admin-API: `GET /api/v1/audio-cpp-runtime`, `GET /api/v1/audio-cpp-runtime/log`, `POST /api/v1/audio-cpp-runtime/install` und `/cancel` (JSON `{}`, vorhandene Sitzung und CSRF-Header). Backup/Restore enthält das gepinnte Laufzeitrezept und baut es bei Bedarf neu; keine Binärdateien im Backup.
+
+## YuE2-Komponenten in der GUI
+
+Unter **Musik → Profile → Komponenten** bietet Deck für das offizielle YuE2-GGUF-Hauptmodell fünf Rollen: VAE (F16 empfohlen oder F32), Modellkonfiguration, Generierungskonfiguration, Qwen-Tokenizer und VAE-Konfiguration. „Alle fehlenden Dateien herunterladen“ reiht je eine Datei pro Rolle in die Download-Warteschlange ein. Danach „Bibliothek aktualisieren“ und „Zuordnung auf Athena speichern“. Bereits vorhandene Dateien werden wiederverwendet. Quelle und Zuweisung müssen dieselbe Hub-Revision wie das Hauptmodell haben. Diese Zusatzdateien sind keine eigenständigen Modelle. Der fehlende Musikworker bleibt auch nach vollständiger Zuweisung als Blocker sichtbar.
diff --git a/profiles-ui.js b/profiles-ui.js
index 65b1709..2b76a49 100644
--- a/profiles-ui.js
+++ b/profiles-ui.js
@@ -7,7 +7,7 @@ window.ProfilesUI=(()=>{
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],entries=[],panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
- el('profile-list').innerHTML=rows.map(p=>`GESPEICHERT AUF ATHENA
${e(p.name)}
${e(p.model?.file||'Modelldatei nicht verfügbar')}
`:''}${CatalogUI.executionHTML(p.execution)}${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`
`:''}${CatalogUI.executionHTML(p.execution)}${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`
${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.
`);
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
@@ -36,7 +36,7 @@ window.ProfilesUI=(()=>{
el('profile-editor').innerHTML='
`:''}`;
const missing=data.requirements?.filter(r=>!r.available.length)||[];
if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{
const b=el('components-download-all');b.disabled=true;let count=0;
diff --git a/profiles.py b/profiles.py
index 46967cd..36cd917 100644
--- a/profiles.py
+++ b/profiles.py
@@ -78,8 +78,19 @@ LTX_COMPONENTS={
def video_recipe(model):
return LTX_COMPONENTS if model.get('kind')=='video' and model.get('repo')==LTX_REPO and model.get('file')==LTX_FILE else None
+YUE2_REPO='audio-cpp/Yue2-3B-GGUF'
+YUE2_COMPONENTS={
+ 'vae':{'label':'YuE2-VAE · F16 empfohlen, F32 alternativ','files':['yue2-vae-f16.gguf','yue2-vae-f32.gguf']},
+ 'model_config':{'label':'Modellkonfiguration','files':['sidecars/yue2-model-config.json']},
+ 'generation_config':{'label':'Generierungskonfiguration','files':['sidecars/yue2-generation-config.json']},
+ 'tokenizer':{'label':'Qwen-Tokenizer','files':['sidecars/yue2-qwen.tiktoken']},
+ 'vae_config':{'label':'VAE-Konfiguration','files':['sidecars/yue2-vae-config.json']}
+}
+def music_recipe(model):
+ return YUE2_COMPONENTS if model.get('kind')=='music' and model.get('repo')==YUE2_REPO and model.get('file') in ('yue2-3b-bf16.gguf','yue2-3b-q8_0.gguf','yue2-3b-q4_0.gguf') else None
+
def component_recipe(model):
- return video_recipe(model) or image_recipe(model)
+ return music_recipe(model) or video_recipe(model) or image_recipe(model)
class Profiles:
def __init__(self,path,catalog):
@@ -111,6 +122,10 @@ class Profiles:
for role,info in video_recipe(p['model']).items():
try:self._component(p['model'],role,p.get('components',{}).get(role))
except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
+ if p.get('model') and music_recipe(p['model']):
+ for role,info in music_recipe(p['model']).items():
+ try:self._component(p['model'],role,p.get('components',{}).get(role))
+ except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
if p['kind']=='video' and self.video_blockers and p.get('model'):p['blockers']=self.video_blockers(p)
if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p)
if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p)
@@ -191,12 +206,12 @@ class Profiles:
if not recipe or role not in recipe:raise ValueError('Für dieses Modell ist kein geprüftes Komponentenrezept hinterlegt.')
item=self.catalog.entry(ident)
if item['repo']!=recipe[role].get('repo',model['repo']) or item['kind']!=model['kind'] or item['file'] not in recipe[role]['files']:raise ValueError('Die Datei gehört nicht zu dieser Komponente des Modells.')
- if video_recipe(model) and item['revision']!=model['revision']:raise ValueError('LTX-Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.')
+ if (video_recipe(model) or music_recipe(model)) and item['revision']!=model['revision']:raise ValueError('Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.')
return item
def components(self,model_id):
model=self.catalog.entry(model_id)
if not component_recipe(model):return dict(supported=False,requirements=[],message='Für dieses Modell ist noch kein Komponentenrezept hinterlegt. Keine automatische Zuordnung.')
- recipe=component_recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
+ recipe=component_recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video or music_recipe(model) else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
for role,info in recipe.items():
available=[]
for item in entries:
@@ -205,6 +220,7 @@ class Profiles:
except ValueError:pass
candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name]
requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates))
+ if music_recipe(model):return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='audio.cpp',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Passendes YuE2-GGUF-Paket: je eine VAE-Datei und die vier Zusatzdateien derselben Quellversion. F16-VAE ist die erste Downloadempfehlung. Vorhandene Dateien wiederverwenden, fehlende herunterladen und danach zuordnen. Der Deck-Musikworker und die Ladypoly-Anbindung fehlen weiterhin; vollständige Komponenten starten kein Modell.')
if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='ComfyUI · gemeinsame Bild- und Videolaufzeit',runtime_installed=bool(getattr(self,'video_runtime_ready',lambda:False)()),message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Vorhandene ComfyUI-Laufzeit wiederverwenden; Videomodell im Bereich Video aktivieren. Vollständige Dateien sind keine Speicherzusage.')
return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(),
message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.')
@@ -212,7 +228,7 @@ class Profiles:
if set(data)!={'id','revision','components'} or not isinstance(data['components'],dict):raise ValueError('Ungültige Komponentenzuordnung.')
with self.lock:
old=next((p for p in self.rows if p['id']==data['id']),None)
- if not old or old['kind'] not in ('image','video') or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
+ if not old or old['kind'] not in ('image','video','music') or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
model=self.catalog.entry(old['model_id'])
if set(data['components'])-set(component_recipe(model) or {}):raise ValueError('Unbekannte Komponente für dieses Modell.')
components={k:v for k,v in data['components'].items() if v}
diff --git a/server.py b/server.py
index c3b4f58..ff53255 100644
--- a/server.py
+++ b/server.py
@@ -117,6 +117,7 @@ class Server(ThreadingHTTPServer):
from ltx_original_runtime import LTXOriginalRuntime
from audio_cpp_runtime import AudioCppRuntime
self.audio_cpp_runtime=AudioCppRuntime(self.catalog.root.parent/'audio-cpp-runtime')
+ self.profiles.music_runtime_ready=lambda:self.audio_cpp_runtime.status()['installed']
self.ltx_original_runtime=LTXOriginalRuntime(self.catalog.root.parent/'ltx-original-runtime',self.image_runtime)
self.video=VideoRuntimes(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime,original_runtime=self.ltx_original_runtime)
self.endpoint.video=self.video
diff --git a/test_music_components.py b/test_music_components.py
new file mode 100644
index 0000000..971c9e7
--- /dev/null
+++ b/test_music_components.py
@@ -0,0 +1,26 @@
+import tempfile,unittest
+from pathlib import Path
+from unittest.mock import Mock
+from profiles import Profiles,YUE2_REPO,YUE2_COMPONENTS,music_recipe
+from catalog import file_role
+class MusicComponentsTests(unittest.TestCase):
+ def test_download_recipe_assignment_and_remaining_worker_block(self):
+ model=dict(id='main',kind='music',repo=YUE2_REPO,file='yue2-3b-bf16.gguf',revision='a'*40)
+ entries=[dict(id=role,kind='music',repo=YUE2_REPO,file=info['files'][0],revision='a'*40,size=100) for role,info in YUE2_COMPONENTS.items()]
+ lookup={e['id']:e for e in [model]+entries}
+ c=Mock();c.entry.side_effect=lambda id:lookup[id] if id in lookup else (_ for _ in ()).throw(ValueError('missing'))
+ c.status.return_value=dict(entries=entries)
+ c.files.return_value=dict(repo=YUE2_REPO,revision='a'*40,files=[dict(name=e['file'],size=100) for e in entries],gated=False,license='apache-2.0')
+ with tempfile.TemporaryDirectory() as d:
+ p=Profiles(Path(d)/'profiles.json',c);p.music_runtime_ready=lambda:True
+ p.rows=[dict(id='profile',kind='music',revision=1,name='YuE2',model_id='main',parameters={})]
+ r=p.components('main');self.assertEqual(len(r['requirements']),5);self.assertTrue(r['runtime_installed']);c.files.assert_called_once_with(YUE2_REPO,'a'*40)
+ self.assertTrue(all(len(x['available'])==1 for x in r['requirements']))
+ p.assign(dict(id='profile',revision=1,components={e['id']:e['id'] for e in entries}))
+ result=p.status()['profiles'][0];self.assertFalse(result['runnable']);self.assertEqual(len(result['blockers']),1)
+ entries[0]['revision']='b'*40
+ with self.assertRaises(ValueError):p._component(model,'vae','vae')
+ def test_components_cannot_be_main_models(self):
+ for info in YUE2_COMPONENTS.values():
+ for filename in info['files']:self.assertFalse(file_role(filename)['profile_eligible'])
+ self.assertIsNone(music_recipe(dict(kind='music',repo='other/Yue2',file='yue2-3b-bf16.gguf')))