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=>`
${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.'}
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')}`;}).join('')}
`).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()