diff --git a/README.md b/README.md index 42ca9a8..84d5b33 100644 --- a/README.md +++ b/README.md @@ -240,3 +240,11 @@ Bildadapter werden zentral in `profiles.IMAGE_ADAPTERS` definiert: Modellfamilie Eigener Werkzeugbereich `#separation` mit BS-Roformer, MelBand und Demucs FT/6s. Die alte Web-GUI läuft optional als CPU-only Container unter Weitere Dienste und nutzt den nativen Deck-Worker. [Installation und Grenzen](deploy/separator-web/README.md). Modellquellen, alle Ensemble-Dateien und SHA-256 sind in `separator_sources.json` festgelegt. Paket-Downloads und Backup-Restore verwenden diese geprüften Rezepte; Nutzmedien werden nicht exportiert. Bei lokalen Bibliotheksdateien wird eine belegte Originalquelle separat vom Übernahmeort angezeigt, ohne IDs oder Profilreferenzen zu ändern. Unbekannte lokale Quellen werden nicht geraten. Audio-Trennung ist auch ein exklusiver Steuerungsmodus neben LLM, Video und Musik. Der Wechsel beendet Deck-eigene GPU-Worker; fremde Dienste bleiben unangetastet. Lange Trennaufträge über die Web-GUI benötigen diesen Modus, der kurze Test bleibt auch im LLM-Modus nutzbar. Sprach-/Bild-/Musik-API-Anfragen erhalten im Trennmodus eine klare 503-Meldung. Der Werkzeugbereich verwendet die gemeinsamen Tabs Entdecken, Bibliothek, Downloads und Testen; Laufzeitinstallation bleibt unter Einstellungen. Weitere Dienste bietet Links und Beschreibung für Separator Web (8128), LadyPoly (8127), LTX DeskWEB (8118) und SwarmUI (8125). + +Unter **Audio-Trennung → Profile** gibt es ein aufklappbares Profil je geprüftem +Modellpaket: Aktivierung für Trennaufträge und Auswahl einer festen GPU oder +„Automatisch · RTX 5080 zuerst“. Automatik nimmt anschließend die RTX 3060, wenn +die 5080 nicht frei genug ist. Eine feste Auswahl fällt nicht still auf eine +andere Karte zurück. Auch die Separator-Weboberfläche beachtet diese Einstellungen. +Profileinstellungen werden im verschlüsselten Backup gespeichert. Die GPU-Prüfung +ist eine Vorprüfung; ein tatsächlicher OOM wird weiterhin als Auftragsfehler angezeigt. diff --git a/backup.py b/backup.py index c64ccfe..4c12be9 100644 --- a/backup.py +++ b/backup.py @@ -7,7 +7,7 @@ from auth import atomic_write,validate_record from backup_codec import seal,open_backup from catalog import repo_id,file_role,KINDS from profiles import SCHEMAS,CHAT_GPU_DEFAULTS,chat_parameters -SETTINGS=('profiles.json','endpoint.json','audio-policy.json','video/comfy-selection.json','models/huggingface.json','video/comfy-client-token','video/original-work/settings.json') +SETTINGS=('separator-profiles.json','profiles.json','endpoint.json','audio-policy.json','video/comfy-selection.json','models/huggingface.json','video/comfy-client-token','video/original-work/settings.json') MAX_HISTORY=40*1024**2 VERSION=1 # Exact byte hash verified against official Hub metadata, not a filename heuristic. @@ -70,6 +70,9 @@ def validate(doc): if not isinstance(hf,dict) or set(hf)!={'token'} or hf['token'] is not None and (not isinstance(hf['token'],str) or not re.fullmatch(r'hf_[A-Za-z0-9]{10,252}',hf['token'])):raise ValueError('Ungültiger Hugging-Face-Zugang.') if 'video/comfy-client-token' in settings and not re.fullmatch(r'[A-Za-z0-9_-]{32,256}',settings['video/comfy-client-token'].strip()):raise ValueError('Ungültiger Diensttoken.') if 'video/original-work/settings.json' in settings and (not isinstance(settings['video/original-work/settings.json'],dict) or len(json.dumps(settings['video/original-work/settings.json']))>65536):raise ValueError('Ungültige LTX-Einstellungen.') + if 'separator-profiles.json' in settings: + from separator import validate_profiles + validate_profiles(settings['separator-profiles.json']) validate_record(doc.get('credentials')) runtimes=doc.get('runtimes') if not isinstance(runtimes,dict) or set(runtimes)-{'llama','llama_builds','image','tts','enhancers','swarm_nodes','ltx_original','audio_cpp','separator'} or not {'llama','llama_builds','image','tts','enhancers','swarm_nodes'}<=set(runtimes):raise ValueError('Ungültiges Laufzeitrezept.') diff --git a/docs/BACKUP_CHECK.md b/docs/BACKUP_CHECK.md index ffa0f48..e2e0f16 100644 --- a/docs/BACKUP_CHECK.md +++ b/docs/BACKUP_CHECK.md @@ -66,3 +66,9 @@ Korrigiert: falsche Warnung über fehlende Quellen bei lokalen Build-Rezepten; Separator-Web erhält beim Restore wieder eine schreibgeschützte Rootfs und seine beschreibbare 1-GiB-Tmpfs. Änderungen benötigen zur Live-Aktivierung das nächste Deck-App-Update und die Aktualisierung des Docker-Systemhelfers. + +Audio-Trennprofile werden zusätzlich in `separator-profiles.json` gesichert: +Aktivierung und GPU-UUID je geprüftem Modellpaket. Alte Backups ohne diese Datei +verwenden aktivierte Profile mit automatischer Auswahl (RTX 5080 zuerst). Bei +geänderter Hardware muss eine fest gespeicherte UUID im Profil neu gewählt werden; +Deck wechselt bei fester Auswahl nicht unbemerkt auf eine andere GPU. diff --git a/separator-ui.js b/separator-ui.js index 4b9cb99..5ed8b25 100644 --- a/separator-ui.js +++ b/separator-ui.js @@ -5,11 +5,12 @@ window.SeparatorUI=(()=>{ function sources(m){return `

Quelle: ${e(m.source?.publisher||'Nicht belegt')}

Originalquellen und Prüfsummen${(m.source?.files||[]).map(f=>`

${e(f.file)}
${f.urls.map(u=>`Downloadquelle`).join(' · ')}
${e(f.sha256)}

`).join('')}
`;} function rows(models,library){const list=models.filter(m=>!library||m.installed);return list.length?`
${list.map(m=>`
${e(m.name)}${m.installed?'Bereit':'Nicht heruntergeladen'}${m.source?.files?.length||0} Dateien

${e(m.id)}

${sources(m)}

Vollständiges Modellpaket · einschließlich Konfiguration und benötigter Teilmodelle. Die Laufzeit wird wiederverwendet.

${m.installed?'In der Steuerung Audio-Trennung starten →':``}
`).join('')}
`:'

Noch keine Modellpakete

Unter Entdecken ein geprüftes Modellpaket herunterladen.

';} function render(tool=false,force=false){if(!force&&document.querySelector('#separator-panel')?.dataset.tool===String(tool))return; - const tabs=[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['test','Testen']]; + const tabs=[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],['test','Testen']]; document.querySelector('#view').innerHTML=`
${tool?'ATHENA / MODELLVERWALTUNG':'EINSTELLUNGEN / LAUFZEITEN'}

${tool?'Audio-Trennung':'Audio Separator'}

${tool?'Gesang, Schlagzeug, Bass, Gitarre, Klavier oder die übrige Begleitung aus einer Aufnahme herauslösen.':'Native CUDA-Laufzeit für BS-Roformer, MelBand und Demucs. Einmal installieren und für passende Modellpakete wiederverwenden.'}

${tool?`
${tabs.map(([id,label])=>``).join('')}
`:''}

`; const root=document.querySelector('#separator-panel'),msg=root.querySelector('#sep-message'),content=root.querySelector('#sep-content');let pending=false,signature=''; - async function action(path,data){pending=true;try{await api(path,data);msg.textContent='Aktion gestartet.';}catch(error){msg.textContent=error.message;}finally{pending=false;signature='';poll();}} + async function action(path,data){pending=true;try{await api(path,data);msg.textContent=path==='separator-profiles/save'?'Profil gespeichert.':'Aktion gestartet.';}catch(error){msg.textContent=error.message;}finally{pending=false;signature='';poll();}} function bind(s,t){ + root.querySelectorAll('[data-sep-profile]').forEach(form=>{form.onsubmit=event=>{event.preventDefault();action('separator-profiles/save',{model:form.dataset.sepProfile,enabled:form.elements.enabled.checked,device:form.elements.device.value});};}); root.querySelectorAll('[data-sep-model]').forEach(b=>{b.disabled=pending||s.job?.state==='running'||!s.installed;b.onclick=()=>action('separator-runtime/download',{model:b.dataset.sepModel});}); const install=root.querySelector('#sep-install');if(install){install.disabled=pending||s.installed||s.job?.state==='running';install.onclick=()=>action('separator-runtime/install',{});} const cancel=root.querySelector('#sep-cancel');if(cancel){cancel.disabled=s.job?.state!=='running';cancel.onclick=()=>action('separator-runtime/cancel',{});} @@ -17,12 +18,13 @@ window.SeparatorUI=(()=>{ const form=root.querySelector('#sep-test');if(form){form.querySelector('button').disabled=pending||t.job?.state==='running'||!s.models.some(m=>m.installed);form.onsubmit=async event=>{event.preventDefault();pending=true;msg.textContent='Audio wird hochgeladen …';try{const response=await fetch('/api/v1/separator-tests/start',{method:'POST',headers:{'X-Athena-Deck':'1'},body:new FormData(form)}),value=await response.json();if(!response.ok)throw Error(value.error);msg.textContent='Trenntest gestartet.';}catch(error){msg.textContent=error.message;}finally{pending=false;signature='';poll();}};} } async function poll(){if(!root.isConnected)return;try{const [s,t]=await Promise.all([api('separator-runtime'),api('separator-tests')]);if(!root.isConnected)return; - const key=JSON.stringify([s,t,pending]);if(key!==signature&& (!root.querySelector('#sep-test')?.contains(document.activeElement)||t.job?.state==='running'||root.dataset.jobState!==String(t.job?.state))){signature=key;root.dataset.jobState=String(t.job?.state); + const key=JSON.stringify([s,t,pending]);if(key!==signature&& (!root.querySelector('form')?.contains(document.activeElement)||t.job?.state==='running'||root.dataset.jobState!==String(t.job?.state))){signature=key;root.dataset.jobState=String(t.job?.state); if(!tool){content.innerHTML=`

Laufzeit

${s.installed?'Installiert':'Noch nicht installiert'} · Version ${e(s.version)}

Mindestens 15 GiB frei. Keine Änderung an Host-Treibern oder alten Diensten.

Audio-Trennung öffnen →

${e(s.job?.phase||'')}

${s.job?.state==='running'?'':''}
Installationsausgabe
`; const log=await api('separator-runtime/log');if(root.isConnected)root.querySelector('#sep-log').textContent=log.text; }else if(section==='discover'||section==='library'){content.innerHTML=`${section==='discover'?'

Geprüfte Pakete mit allen benötigten Zusatzdateien. Downloads prüfen die Originalquellen per SHA-256.

':''}${!s.installed?'

Laufzeit fehlt. Audio Separator installieren →

':''}${rows(s.models,section==='library')}`; + }else if(section==='profiles'){content.innerHTML=`${t.hardware_error?`

${e(t.hardware_error)}

`:''}

Ein Profil pro Modellpaket. Aktivierte Profile sind für die Weboberfläche verfügbar. Gewichte laden bei Auftrag und werden danach entladen. Automatisch bevorzugt die RTX 5080, anschließend die RTX 3060; eine feste GPU wird niemals still gewechselt.

${s.models.map(m=>{const p=t.profiles[m.id];return `
${e(m.name)}${p.enabled?'Aktiv':'Deaktiviert'}${e(p.device==='auto'?'Automatisch · 5080 zuerst':t.devices.find(d=>d.uuid===p.device)?.name||'GPU nicht vorhanden')}

${m.installed?'Modellpaket bereit':'Modellpaket noch herunterladen'}

`;}).join('')}
`; }else if(section==='downloads'){content.innerHTML=s.job?`
${e(s.job.state)}

Einrichtungsauftrag

${e(s.job.phase)}

${s.job.state==='running'?'':''}

Abgeschlossene Modellpakete findest du in der Bibliothek.

`:'

Keine Downloads

Unter Entdecken ein Modellpaket auswählen.

'; - }else{const j=t.job;content.innerHTML=`

Audio-Trennung testen

Kurzer PCM-WAV-Test: 16 Bit, Mono/Stereo, bis 30 Sekunden und 8 MiB. Für längere Dateien die Weboberfläche verwenden.

Audio-Trennoberfläche öffnen → · Audio-Trennung in der Steuerung starten →

Gewichte laden bei Auftrag und werden anschließend entladen. Fremde Dienste bleiben unberührt.

${j?`

${e(j.state)} · ${e(j.phase)} ${j.elapsed_seconds?e(j.elapsed_seconds)+' s':''}

${j.state==='running'?'':''}`:''}
${j?.state==='complete'?j.files.map(f=>`

${e(f.name)}

Spur herunterladen

`).join(''):''}
`;} + }else{const j=t.job;content.innerHTML=`

Audio-Trennung testen

Kurzer PCM-WAV-Test: 16 Bit, Mono/Stereo, bis 30 Sekunden und 8 MiB. Für längere Dateien die Weboberfläche verwenden.

Audio-Trennoberfläche öffnen → · Audio-Trennung in der Steuerung starten →

Gewichte laden bei Auftrag und werden anschließend entladen. Fremde Dienste bleiben unberührt.

${j?`

${e(j.state)} · ${e(j.phase)} ${j.gpu?'· '+e(j.gpu):''} ${j.elapsed_seconds?e(j.elapsed_seconds)+' s':''}

${j.state==='running'?'':''}`:''}
${j?.state==='complete'?j.files.map(f=>`

${e(f.name)}

Spur herunterladen

`).join(''):''}
`;} } bind(s,t); }catch(error){if(root.isConnected)msg.textContent=error.message;}clearTimeout(root.pollTimer);if(root.isConnected)root.pollTimer=setTimeout(poll,3000);} diff --git a/separator.py b/separator.py index 5b46adb..635380a 100644 --- a/separator.py +++ b/separator.py @@ -1,14 +1,46 @@ """Bounded WAV tests with coordinated GPU ownership and disposable input.""" -import io,json,os,signal,subprocess,threading,time,uuid,wave +import io,json,os,re,signal,subprocess,threading,time,uuid,wave from pathlib import Path from image_test import probe from separator_runtime import MODELS +def validate_profiles(settings): + if not isinstance(settings,dict) or set(settings)-set(MODELS):raise ValueError('Unbekanntes Audio-Trennprofil.') + for value in settings.values(): + if not isinstance(value,dict) or set(value)!={'enabled','device'} or type(value['enabled']) is not bool or not isinstance(value['device'],str) or value['device']!='auto' and not re.fullmatch(r'GPU-[a-fA-F0-9-]{16,64}',value['device']):raise ValueError('Ungültiges Audio-Trennprofil.') + return settings + class SeparatorTests: def __init__(self,root,runtime,scheduler): self.root=Path(root);self.runtime=runtime;self.scheduler=scheduler;self.lock=threading.RLock();self.job=None;self.process=None;self.cancel=threading.Event();self.thread=None + def profiles(self): + path=self.root.parent/'separator-profiles.json' + settings=validate_profiles(json.loads(path.read_text())) if path.exists() else {} + return {name:dict(settings.get(name,dict(enabled=True,device='auto'))) for name in MODELS} + def configure(self,data): + if not isinstance(data,dict) or set(data)!={'model','enabled','device'} or data['model'] not in MODELS:raise ValueError('Modell, Aktivierung und GPU erforderlich.') + validate_profiles({data['model']:{k:data[k] for k in ('enabled','device')}}) + if data['device']!='auto' and not any(g['uuid']==data['device'] for g in probe()):raise ValueError('Gewählte GPU ist nicht vorhanden.') + with self.lock: + if self.job and self.job['state']=='running':raise ValueError('Zuerst den laufenden Trennauftrag beenden.') + settings=self.profiles();settings[data['model']]={k:data[k] for k in ('enabled','device')} + from auth import atomic_write + atomic_write(self.root.parent/'separator-profiles.json',settings) + return self.status() def status(self): - with self.lock:return dict(job=dict(self.job) if self.job else None) + try:devices=[dict(uuid=g['uuid'],name=g['name']) for g in probe()];hardware_error=None + except (OSError,ValueError,subprocess.SubprocessError):devices=[];hardware_error='GPU-Erkennung derzeit nicht verfügbar.' + with self.lock:return dict(job=dict(self.job) if self.job else None,profiles=self.profiles(),devices=devices,hardware_error=hardware_error) + def select_gpu(self,model): + settings=self.profiles()[model] + if not settings['enabled']:raise ValueError('Dieses Audio-Trennprofil ist deaktiviert. Unter Profile aktivieren.') + available=[g for g in probe() if not g['processes'] and g['free_mib']>4000] + if settings['device']!='auto': + gpu=next((g for g in available if g['uuid']==settings['device']),None) + if not gpu:raise ValueError('Die gewählte GPU ist nicht ausreichend frei. GPU-Auswahl im Profil prüfen; kein stiller Wechsel.') + return gpu + return next((g for term in ('RTX 5080','RTX 3060','') for g in available if term in g['name']),None) def start(self,model,audio,target=None,full=False): + if isinstance(model,str) and model in MODELS and not self.profiles()[model]['enabled']:raise ValueError('Dieses Audio-Trennprofil ist deaktiviert. Unter Profile aktivieren.') if full and self.scheduler.gpu_mode!='separator':raise ValueError('Bitte unter Steuerung Audio-Trennung starten.') targets={'vocals':'model_bs_roformer_ep_317_sdr_12.9755.ckpt','drums':'htdemucs_ft.yaml','bass':'htdemucs_ft.yaml','guitar':'htdemucs_6s.yaml','piano':'htdemucs_6s.yaml','other':'htdemucs_6s.yaml'} if target is not None and targets.get(target)!=model:raise ValueError('Zielspur und Modellpaket passen nicht zusammen.') @@ -27,7 +59,7 @@ class SeparatorTests: release=None;directory=self.root/self.job['id'];state='failed';phase='Trennung fehlgeschlagen.' try: release=self.scheduler.image_reservation(kind='separator') - gpu=next((g for term in ('RTX 3060','RTX 5080') for g in probe() if term in g['name'] and not g['processes'] and g['free_mib']>4000),None) + gpu=self.select_gpu(model) if not gpu:raise ValueError('Keine ausreichend freie GPU. Fremde Dienste werden nicht beendet.') directory.mkdir(mode=0o700);(directory/'input.wav').write_bytes(audio);diagnostics=(directory/'worker.tmp').open('wb') with self.lock: diff --git a/server.py b/server.py index c1daec3..8e5e47d 100644 --- a/server.py +++ b/server.py @@ -539,6 +539,9 @@ class Handler(BaseHTTPRequestHandler): if data:raise ValueError('Keine Parameter erwartet.') return self.respond(self.server.chat_tests.stop() if self.path.endswith('/cancel') else self.server.chat_tests.unload()) except ValueError as exc:return self.respond({'error':str(exc)},400) + if self.path == '/api/v1/separator-profiles/save': + try:return self.respond(self.server.separator_tests.configure(self.read_json())) + except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400) if self.path in ('/api/v1/separator-runtime/install','/api/v1/separator-runtime/cancel','/api/v1/separator-runtime/download'): try: data=self.read_json() diff --git a/test_backup.py b/test_backup.py index 4106461..e42f27c 100644 --- a/test_backup.py +++ b/test_backup.py @@ -110,6 +110,13 @@ class RestoreTests(unittest.TestCase): summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password') self.assertTrue(summary['services'][0]['restorable']) self.assertFalse(any('Buildquelle fehlt' in w for w in summary['warnings'])) + def test_separator_profile_settings_roundtrip(self): + from separator_runtime import MODELS + name=next(iter(MODELS));settings={name:dict(enabled=False,device='GPU-'+'a'*32)} + (self.root/'separator-profiles.json').write_text(json.dumps(settings)) + doc=self.server.backup.snapshot(include_history=False);validate(doc) + (self.root/'separator-profiles.json').unlink();self.server.backup._apply_settings(doc) + self.assertEqual(self.server.separator_tests.profiles()[name],settings[name]) def test_rejects_unsafe_paths_and_bad_references_before_changes(self): ident=self.model();self.profile(ident);doc=self.server.backup.snapshot(include_history=False) for change in (lambda d:d['models'][0].update(file='../secret'),lambda d:d['settings']['profiles.json'][0].update(model_id='f'*64),lambda d:d.update(version=99)): diff --git a/test_separator.py b/test_separator.py index b15b6bd..f9ed256 100644 --- a/test_separator.py +++ b/test_separator.py @@ -61,4 +61,17 @@ class Tests(unittest.TestCase): worker=SeparatorTests(root,Mock(),scheduler) with self.assertRaisesRegex(ValueError,'Steuerung'):worker.start(next(iter(MODELS)),audio(),full=True) + def test_profiles_prefer_5080_and_fixed_device_never_falls_back(self): + with tempfile.TemporaryDirectory() as root: + worker=SeparatorTests(Path(root)/'separator-tests',Mock(),Mock());model=next(iter(MODELS)) + fast={'name':'RTX 5080','uuid':'GPU-'+'a'*32,'processes':0,'free_mib':12000} + slow={'name':'RTX 3060','uuid':'GPU-'+'b'*32,'processes':0,'free_mib':11000} + with patch('separator.probe',return_value=[slow,fast]): + self.assertEqual(worker.select_gpu(model)['uuid'],fast['uuid']) + worker.configure(dict(model=model,enabled=True,device=fast['uuid'])) + with patch('separator.probe',return_value=[slow,dict(fast,processes=1)]): + with self.assertRaisesRegex(ValueError,'kein stiller Wechsel'):worker.select_gpu(model) + with patch('separator.probe',return_value=[fast]):worker.configure(dict(model=model,enabled=False,device='auto')) + restored=SeparatorTests(Path(root)/'separator-tests',Mock(),Mock()) + with self.assertRaisesRegex(ValueError,'deaktiviert'):restored.start(model,audio()) if __name__=='__main__':unittest.main()