From 433e6709cf5a4abc15055ed1fa53ac8ef97cb72a Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:19:33 +0200 Subject: [PATCH] Add audio separation web app and verified recovery sources --- README.md | 4 ++ app.js | 2 +- backup.py | 11 +++- catalog-ui.js | 2 +- catalog.py | 6 +- deploy/Dockerfile | 2 +- deploy/Dockerfile.app-update | 2 +- deploy/docker_backup.py | 14 ++-- deploy/install.py | 2 +- deploy/separator-web/Dockerfile | 6 ++ deploy/separator-web/README.md | 29 ++++++++ deploy/separator-web/app.py | 78 ++++++++++++++++++++++ deploy/separator-web/index.html | 19 ++++++ deploy/setup_docker_helper.py | 3 + index.html | 2 +- profiles-ui.js | 4 +- separator-ui.js | 4 +- separator.py | 18 ++--- separator_runtime.py | 49 ++++++++++++-- separator_sources.json | 113 ++++++++++++++++++++++++++++++++ separator_worker.py | 18 ++++- server.py | 10 +-- stt.py | 6 +- test_separator.py | 4 +- test_separator_sources.py | 19 ++++++ 25 files changed, 381 insertions(+), 46 deletions(-) create mode 100644 deploy/separator-web/Dockerfile create mode 100644 deploy/separator-web/README.md create mode 100644 deploy/separator-web/app.py create mode 100644 deploy/separator-web/index.html create mode 100644 separator_sources.json create mode 100644 test_separator_sources.py diff --git a/README.md b/README.md index 4f138a1..b0eb004 100644 --- a/README.md +++ b/README.md @@ -234,3 +234,7 @@ Sprachmodell-GPUs und Vision-Projektor werden unabhängig zugeordnet. Der Modell Referenzbilder: Alle derzeit ausführbaren Bildrezepte (Qwen Image 2.1 und FLUX.2 Klein 9B) erlauben bis zu vier Bilder pro Bearbeitungsauftrag. FLUX nutzt VAEEncode und verkettete ReferenceLatent-Nodes für beide Conditioning-Zweige nach dem offiziellen ComfyUI-Workflow: https://github.com/Comfy-Org/workflow_templates/blob/main/templates/image_flux2_klein_image_edit_9b_distilled.json . Neue Modellfamilien benötigen einen passenden Workflow; hochgeladene Referenzen werden niemals stillschweigend verworfen. Bildadapter werden zentral in `profiles.IMAGE_ADAPTERS` definiert: Modellfamilie, unterstütztes Gewichtsformat, Komponenten, Laufzeit, Workflow, Referenzbildlimit und Speicherpolitik. Bibliothek, Profile und Bildworker verwenden denselben Resolver `image_adapter`. Bekannte Quellen bleiben kompatibel; neue Varianten können über gespeicherte präzise Architektur-Metadaten oder die Hugging-Face-Basismodell-Zuordnung dieselbe Anbindung wiederverwenden. Ein Name mit „Qwen“ oder „Flux“ genügt nicht. Allgemeine FLUX-Architekturangaben unterscheiden Klein 9B nicht sicher von anderen FLUX-Varianten und schalten daher keinen Worker frei. Metadaten sind eine Zuordnungshilfe, keine Garantie für den Inhalt oder die Qualität veränderter Gewichte. Neue Architekturen und Dateiformate benötigen einen registrierten Adapter. Bereits heruntergeladene Varianten ohne ausreichende Metadaten bleiben ungeprüft; bestehende bekannte Profile bleiben erhalten. + +### Audio-Trennung + +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. diff --git a/app.js b/app.js index 979c51a..d98ff57 100644 --- a/app.js +++ b/app.js @@ -24,7 +24,7 @@ function appearancePage(){ sync(); } async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'dashboard';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; -if(page==='separator-runtime'||page==='separator-test'){SeparatorUI.render(page==='separator-test');return;} +if(['separator-runtime','separator-test','separation'].includes(page)){SeparatorUI.render(page!=='separator-runtime');return;} if(page==='audio-cpp-runtime'){AudioCppUI.render();return;} if(page==='ltx-original-runtime'){LTXOriginalUI.render();return;} if(['video','video-runtime'].includes(page)){VideoUI.runtime();return;} diff --git a/backup.py b/backup.py index d84a59b..92bff04 100644 --- a/backup.py +++ b/backup.py @@ -76,7 +76,10 @@ def validate(doc): if 'separator' in runtimes: from separator_runtime import MODELS value=runtimes['separator'] - if not isinstance(value,dict) or set(value)!={'installed','models'} or type(value['installed']) is not bool or not isinstance(value['models'],list) or len(value['models'])>len(MODELS) or any(not isinstance(m,str) or m not in MODELS for m in value['models']) or len(set(value['models']))!=len(value['models']):raise ValueError('Ungültiges Audio-Separator-Rezept.') + if not isinstance(value,dict) or set(value)-{'installed','models','sources'} or not {'installed','models'}<=set(value) or type(value['installed']) is not bool or not isinstance(value['models'],list) or len(value['models'])>len(MODELS) or any(not isinstance(m,str) or m not in MODELS for m in value['models']) or len(set(value['models']))!=len(value['models']):raise ValueError('Ungültiges Audio-Separator-Rezept.') + if 'sources' in value: + from separator_runtime import SOURCES + if value['sources']!={name:SOURCES[name] for name in value['models']}:raise ValueError('Audio-Separator-Quellen stimmen nicht mit dem geprüften Rezept überein.') if 'audio_cpp' in runtimes and type(runtimes['audio_cpp']) is not bool:raise ValueError('Ungültige audio.cpp-Laufzeit.') if 'ltx_original' in runtimes and type(runtimes['ltx_original']) is not bool:raise ValueError('Ungültige LTX-Laufzeit.') if not isinstance(runtimes['llama_builds'],list) or len(runtimes['llama_builds'])>50 or any(not isinstance(b,dict) or set(b)!={'commit','backend'} or not re.fullmatch('[a-f0-9]{40}',b.get('commit','')) or b.get('backend') not in ('CUDA','CPU') for b in runtimes['llama_builds']):raise ValueError('Ungültige Buildrezepte.') @@ -144,7 +147,8 @@ class Backup: else:warnings.append('Deck-Netzwerkmodul deaktiviert; fremder WireGuard-Gateway wird nicht gesichert.') document=dict(format='athena-deck',version=VERSION,created_at=time.time(),settings=settings,models=models,credentials=s.credentials.read(),theme=theme,network=network,services=services,runtime_versions=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']),runtimes=dict(audio_cpp=bool(getattr(s,'audio_cpp_runtime',None) and s.audio_cpp_runtime.status()['installed']),ltx_original=bool(getattr(s,'ltx_original_runtime',None) and s.ltx_original_runtime.status()['installed']),llama=llama,llama_builds=[dict(commit=b['commit'],backend=b['backend']) for b in state['builds']],image=s.image_runtime.status()['installed'],tts=s.tts_runtime.status()['installed'],enhancers={task:data['revision'] for task in ('t2i','i2i') if (data:=s.prompt_enhancer.installed(task))},swarm_nodes=(self.root/'video/swarm-comfy-nodes').is_dir()),warnings=warnings) if getattr(s,'separator_runtime',None): - separator=s.separator_runtime.status();document['runtimes']['separator']=dict(installed=separator['installed'],models=[m['id'] for m in separator['models'] if m['installed']]);document['runtime_versions']['separator']=separator['version'] + from separator_runtime import SOURCES + separator=s.separator_runtime.status();document['runtimes']['separator']=dict(installed=separator['installed'],models=[m['id'] for m in separator['models'] if m['installed']],sources={m['id']:SOURCES[m['id']] for m in separator['models'] if m['installed']});document['runtime_versions']['separator']=separator['version'] if document['runtimes']['audio_cpp']: from audio_cpp_runtime import REVISION document['runtime_versions']['audio_cpp']=REVISION @@ -244,8 +248,9 @@ class Backup: if r.get('separator',{}).get('installed'): if not s.separator_runtime.status()['installed']: self._phase('Audio Separator installieren');s.separator_runtime.start();self._wait(s.separator_runtime.status) + from separator_runtime import verify_package for name in r['separator']['models']: - if not s.separator_runtime.model_ready(name): + if not s.separator_runtime.model_ready(name) or not verify_package(s.separator_runtime.root/'models',name): self._phase('Audio-Separator-Modellpaket laden');s.separator_runtime.download(name);self._wait(s.separator_runtime.status) targets=r['llama_builds']+([r['llama']] if r['llama'] and r['llama'] not in r['llama_builds'] else []) for target in targets: diff --git a/catalog-ui.js b/catalog-ui.js index 5098819..1e2d885 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -34,7 +34,7 @@ window.CatalogUI = (() => { const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId)); const entries = state.entries.filter(x => x.kind === kind); const groups = [['model', kind==='chat'?'Sprachmodelle · Hauptgewichte':'Modelle · Hauptgewichte'],['vision_projector','Vision-Projektoren'],['audio_projector','Audio-Projektoren'],['text_encoder','Textencoder'],['vae','VAE · Decoder'],['configuration','Konfigurationen'],['auxiliary','Tokenizer und weitere Zusatzkomponenten']]; - const row = x => `
${escape(x.file)}${escape(x.role_label)}${size(x.size)}${x.used_by_profiles?.length?`${x.used_by_profiles.length} Profil(e)`:'Nicht zugeordnet'}

Quelle: ${escape(x.repo)}

${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${executionHTML(x.execution)}${x.used_by_profiles?.length?`

Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.

`:""}
${x.profile_eligible===true?``:""}
`; + const row = x => `
${escape(x.file)}${escape(x.role_label)}${size(x.size)}${x.used_by_profiles?.length?`${x.used_by_profiles.length} Profil(e)`:'Nicht zugeordnet'}

Quelle: ${escape(x.download_source?.repo||x.repo)}

${x.download_source?`

Lokal übernommen · Originaldatei: ${escape(x.download_source.file)} · Quellrevision: ${escape(x.download_source.revision)}

`:""}

${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${executionHTML(x.execution)}${x.used_by_profiles?.length?`

Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.

`:""}
${x.profile_eligible===true?``:""}
`; const known = new Set(groups.map(([role])=>role)); const sections = [...groups, ['other','Weitere Dateien · Zuordnung unbekannt']]; el('hub-results').innerHTML = sections.map(([role,label])=>{const files=entries.filter(x=>role==='other'?!known.has(x.role):x.role===role);return files.length?`

${escape(label)} (${files.length})

${files.map(row).join('')}
`:'';}).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; diff --git a/catalog.py b/catalog.py index 4f2bd48..7f9ceb6 100644 --- a/catalog.py +++ b/catalog.py @@ -216,7 +216,11 @@ class Catalog: if self.root.exists(): for p in self.root.glob('*/entry.json'): try: - item=json.loads(p.read_text());item['id']=p.parent.name;item.update(file_role(item['file']));entries.append(item) + item=json.loads(p.read_text());item['id']=p.parent.name;item.update(file_role(item['file'])) + if item['repo'].startswith('local/'): + from backup import LOCAL_SOURCES + item['download_source']=item.get('download_source') or LOCAL_SOURCES.get(item.get('sha256')) + entries.append(item) except (OSError,ValueError):pass return dict(entries=entries,downloads=[dict(x) for x in reversed(self.history) if not x.get("dismissed")],job=dict(self.job) if self.job else None,free_bytes=shutil.disk_usage(self.root if self.root.exists() else self.root.parent).free) def start(self,repo,filename,revision,kind): diff --git a/deploy/Dockerfile b/deploy/Dockerfile index ec4b3a3..f00bcbf 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -16,7 +16,7 @@ WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock COPY deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock -COPY separator.py separator_runtime.py separator_worker.py music.py audio_cpp_runtime.py ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/ +COPY separator_sources.json separator.py separator_runtime.py separator_worker.py music.py audio_cpp_runtime.py ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/ COPY separator-ui.js music-ui.js audio-cpp-ui.js ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/ COPY deploy/docker_backup.py /app/deploy/docker_backup.py COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ diff --git a/deploy/Dockerfile.app-update b/deploy/Dockerfile.app-update index b4eb780..5e54f6a 100644 --- a/deploy/Dockerfile.app-update +++ b/deploy/Dockerfile.app-update @@ -6,7 +6,7 @@ WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock COPY deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock -COPY separator.py separator_runtime.py separator_worker.py music.py audio_cpp_runtime.py ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/ +COPY separator_sources.json separator.py separator_runtime.py separator_worker.py music.py audio_cpp_runtime.py ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/ COPY separator-ui.js music-ui.js audio-cpp-ui.js ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/ COPY deploy/docker_backup.py /app/deploy/docker_backup.py COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ diff --git a/deploy/docker_backup.py b/deploy/docker_backup.py index 645d580..e2791e7 100644 --- a/deploy/docker_backup.py +++ b/deploy/docker_backup.py @@ -53,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 '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')))) + 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 'separator-web' if row['name']=='athena-separator-web' 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): @@ -61,8 +61,8 @@ 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','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('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb','athena-ladypoly','athena-separator-web'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.') + recipes={'separator-web':('athena-separator-web','athena-separator-web:0.1.0'),'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.') @@ -97,7 +97,7 @@ def validate(c): 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(): allowed=('file','Settings.fds','Backends.fds') - if c['name']=='athena-ladypoly' and m['target']=='/data':allowed+=('settings.json','deck-connection.json') + if c['name'] in ('athena-ladypoly','athena-separator-web') 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 @@ -115,9 +115,9 @@ 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['name']=='athena-ladypoly': - with socket.socket() as probe:probe.bind(('127.0.0.1',8127)) - if c.get('build_recipe') in ('swarm-ui','ladypoly'): + if c['name'] in ('athena-ladypoly','athena-separator-web'): + with socket.socket() as probe:probe.bind(('127.0.0.1',8127 if c['name']=='athena-ladypoly' else 8128)) + if c.get('build_recipe') in ('swarm-ui','ladypoly','separator-web'): 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) diff --git a/deploy/install.py b/deploy/install.py index 3d6369d..afa33ac 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['separator.py','separator_runtime.py','separator_worker.py','separator-ui.js','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'] +FILES = ['separator_sources.json','deploy/separator-web/Dockerfile','deploy/separator-web/app.py','deploy/separator-web/index.html','separator.py','separator_runtime.py','separator_worker.py','separator-ui.js','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): diff --git a/deploy/separator-web/Dockerfile b/deploy/separator-web/Dockerfile new file mode 100644 index 0000000..0ff6f7d --- /dev/null +++ b/deploy/separator-web/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim-bookworm +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY app.py index.html ./ +USER 65534:65534 +CMD ["python","app.py"] diff --git a/deploy/separator-web/README.md b/deploy/separator-web/README.md new file mode 100644 index 0000000..8248639 --- /dev/null +++ b/deploy/separator-web/README.md @@ -0,0 +1,29 @@ +# Athena Audio-Trennung Web-GUI + +Die bisherige Athena-GUI als CPU-only Container. Keine Modelle, CUDA oder Docker-Socket im Container. Native Verarbeitung durch Decks audio-separator 0.47.0. + +Build: `docker build -t athena-separator-web:0.1.0 deploy/separator-web` + +Start auf Debian nach Prüfung des freien Ports: + +```sh +sudo install -d -o 65534 -g 65534 -m 700 /opt/athena-separator-web/data +docker run -d --name athena-separator-web --network host --restart unless-stopped \ + --read-only --cap-drop ALL --security-opt no-new-privileges:true \ + --memory 2g --cpus 2 --tmpfs /tmp:rw,noexec,nosuid,size=1g,uid=65534,gid=65534 \ + --label io.athena-deck.managed=true --label io.athena-deck.role=application \ + --label io.athena-deck.application=separator-web --label io.athena-deck.port=8128 \ + -v /opt/athena-separator-web/data:/data:rw athena-separator-web:0.1.0 +``` + +GUI bindet nur 127.0.0.1:8128; Verbindung zu Decks Management-API auf 127.0.0.1:8108. In der GUI einmal Deck-API-Token eintragen. Er wird serverseitig mit Modus 0600 gespeichert und niemals zurückgegeben. Loopback/SSH-Tunnel benutzen. + +Mac-Tunnel: `ssh -i ~/.ssh/athena_key -o BatchMode=yes -N -L 8128:127.0.0.1:8128 root@192.168.1.212` + +URL http://127.0.0.1:8128/ . Stoppen/starten/löschen über Weitere Dienste oder docker stop/start/rm athena-separator-web. Volume enthält ausschließlich Verbindungsdaten. Audio wird temporär konvertiert; Deck-Ergebnisse sind nicht im Konfigurationsbackup enthalten. + +Unterstützt Gesang (BS-Roformer), Schlagzeug/Bass (Demucs FT), Gitarre/Klavier/Rest (Demucs 6s). Kein ClearVoice. Upload bis 256 MiB, maximal 20 Minuten. Native Worker laufen pro Auftrag; dauerhaftes Warmhalten ist noch nicht implementiert. Bei fremder GPU-Belegung wird abgelehnt, keine fremden Dienste werden beendet. + +Modellquellen einschließlich aller Ensemble-Dateien und SHA-256 stehen in separator_sources.json. Paket-Downloads und Restore prüfen alle Hashes. Quelle darf GitHub/UVR oder Meta sein; es wird keine Hugging-Face-Herkunft erfunden. Lokale Übernahme erhält IDs und wird separat als Übernahme dargestellt. Unbekannte lokale Dateien benötigen weiterhin einen belegten Herkunftsabgleich. + +Backup speichert Paketrezepte, Quellen und Container-Konfiguration. Der Docker-Helfer kann das hier hinterlegte Image-Rezept auf einer leeren Installation wieder bauen; Gewichte werden von den geprüften Quellen erneut bezogen. diff --git a/deploy/separator-web/app.py b/deploy/separator-web/app.py new file mode 100644 index 0000000..8a8bc3a --- /dev/null +++ b/deploy/separator-web/app.py @@ -0,0 +1,78 @@ +"""Old Athena separation UI, remote Deck processing; no CUDA or models here.""" +import io,json,os,secrets,subprocess,tempfile,time,urllib.request,urllib.error,zipfile +from pathlib import Path +from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer +from email import policy +from email.parser import BytesParser +BASE='http://127.0.0.1:8108' +CONFIG=Path('/data/deck-connection.json') +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'} +def request(path,body=None,ctype='application/json',token=None): + if token is None:token=json.loads(CONFIG.read_text())['token'] if CONFIG.exists() else '' + if not token:raise ValueError('Zuerst die Deck-Verbindung einrichten.') + req=urllib.request.Request(BASE+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':ctype}) + try: + with urllib.request.urlopen(req,timeout=90) as response:return response.read() + except urllib.error.HTTPError as exc: + try:message=json.loads(exc.read()).get('error','Deck-Anfrage abgelehnt') + except Exception:message='Deck-Anfrage abgelehnt' + raise ValueError(message if isinstance(message,str) else message.get('message','Deck-Anfrage abgelehnt')) from None +class Handler(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def send(self,body,status=200,mime='application/json'): + if isinstance(body,dict):body=json.dumps(body).encode() + self.send_response(status);self.send_header('Content-Type',mime);self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff');self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'");self.end_headers();self.wfile.write(body) + def do_GET(self): + if self.path=='/health':return self.send({'status':'ok','connected':CONFIG.exists()}) + if self.path=='/':return self.send(Path('/app/index.html').read_bytes(),mime='text/html; charset=utf-8') + if self.path=='/api/status': + try:return self.send(request('/api/v1/separator-runtime')) + except ValueError as exc:return self.send({'detail':str(exc)},400) + return self.send({'detail':'Nicht gefunden'},404) + def do_POST(self): + origin=self.headers.get('Origin');host=self.headers.get('Host','') + if self.headers.get('X-Athena-UI')!='1' or origin and origin!='http://'+host:return self.send({'detail':'Same-origin erforderlich'},403) + try: + length=int(self.headers.get('Content-Length','0')) + if self.headers.get('Transfer-Encoding') or not 04096:raise ValueError('Verbindungsdaten zu groß.') + value=json.loads(raw);token=value.get('token','') + if not isinstance(token,str) or not 1<=len(token)<=256:raise ValueError('Ungültiger Token.') + request('/api/v1/separator-runtime',token=token) + CONFIG.parent.mkdir(exist_ok=True);tmp=CONFIG.with_suffix('.tmp');tmp.write_text(json.dumps({'token':token}));tmp.chmod(0o600);tmp.replace(CONFIG) + return self.send({'ok':True}) + if self.path!='/v1/separate':return self.send({'detail':'Nicht gefunden'},404) + ctype=self.headers.get('Content-Type','') + if not ctype.startswith('multipart/form-data;'):raise ValueError('Audiodatei fehlt.') + msg=BytesParser(policy=policy.default).parsebytes(b'Content-Type: '+ctype.encode()+b'\r\n\r\n'+raw) + fields={} + if not msg.is_multipart() or msg.defects:raise ValueError('Ungültiger Upload.') + for part in msg.iter_parts(): + name=part.get_param('name',header='content-disposition') + if name not in ('file','target') or name in fields:raise ValueError('Ungültiges Upload-Feld.') + fields[name]=part.get_payload(decode=True) + target=fields.get('target',b'').decode() + if target not in TARGETS:raise ValueError('Zielspur noch nicht angebunden.') + with tempfile.TemporaryDirectory() as work: + source=Path(work)/'input';source.write_bytes(fields['file']);wav=Path(work)/'input.wav' + subprocess.run(['ffmpeg','-nostdin','-hide_banner','-loglevel','error','-y','-i',str(source),'-t','1201','-ac','2','-ar','44100','-c:a','pcm_s16le',str(wav)],check=True,timeout=120,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + data=wav.read_bytes();boundary=secrets.token_hex(24) + body=b'' + for name,value in [('model',TARGETS[target]),('target',target)]:body+=f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode() + body+=f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="input.wav"\r\nContent-Type: audio/wav\r\n\r\n'.encode()+data+f'\r\n--{boundary}--\r\n'.encode() + job=json.loads(request('/api/v1/separator-jobs/start',body,'multipart/form-data; boundary='+boundary));ident=job['id'];deadline=time.monotonic()+7250 + while time.monotonic() + +Athena · Spuren herauslösen
Athena Audio Lab

Was möchtest du herauslösen?

Der Download enthält immer die gewählte Spur separat und zusätzlich den vollständigen Rest ohne diese Spur.

+
Deck-Verbindung einrichten

API-Token aus Athena Deck → Zugang & API-Token. Er bleibt nur auf dem Server gespeichert.

Modellpakete werden geprüft …

+
Musik
+ + + + + + + + +
+
Bereit.
Verarbeitung durch Athena Deck. Bis 20 Minuten und 256 MiB; Ausgabe als WAV-ZIP. Modell wird nach dem Auftrag entladen. Synthesizer, Streicher sowie elektrische und akustische Gitarre separat benötigen zusätzliche Spezialmodelle.
diff --git a/deploy/setup_docker_helper.py b/deploy/setup_docker_helper.py index 53674d2..e5be7ab 100644 --- a/deploy/setup_docker_helper.py +++ b/deploy/setup_docker_helper.py @@ -28,6 +28,9 @@ def main(): (base/'ladypoly').mkdir(exist_ok=True) for name in ('Dockerfile','bridge.py'): shutil.copyfile(Path(__file__).parent/'ladypoly'/name,base/'ladypoly'/name) + (base/'separator-web').mkdir(exist_ok=True) + for name in ('Dockerfile','app.py','index.html'): + shutil.copyfile(Path(__file__).parent/'separator-web'/name,base/'separator-web'/name) if args.deck_state: registered=Path(args.deck_state).resolve() if not registered.is_dir():raise SystemExit('Deck-Zustandsverzeichnis fehlt.') diff --git a/index.html b/index.html index 32ba879..76bc678 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
+Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
diff --git a/profiles-ui.js b/profiles-ui.js index 4339c77..a83ae8e 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -64,7 +64,7 @@ window.ProfilesUI=(()=>{ async function refreshLibrary(){ const c=await api('catalog');if(!box.isConnected)return; const selected=select.value;entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf')); - select.innerHTML=''+projectors.map(x=>``).join('');select.value=selected; + select.innerHTML=''+projectors.map(x=>``).join('');select.value=selected; return c; } function showFiles(detail,target,n){ @@ -135,7 +135,7 @@ window.ProfilesUI=(()=>{ panelSequence++; if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))}; if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;} - el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`

KV-Cache

${["k","v"].map(axis=>``).join("")}

q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

`; + el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`

KV-Cache

${["k","v"].map(axis=>``).join("")}

q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

`; if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));} if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();} if(kind==='video')bindVideoDevices(data); diff --git a/separator-ui.js b/separator-ui.js index 8a54d90..0f444e3 100644 --- a/separator-ui.js +++ b/separator-ui.js @@ -2,13 +2,13 @@ window.SeparatorUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); async function api(path,data){const response=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const value=await response.json();if(!response.ok)throw Error(value.error||'Anfrage fehlgeschlagen');return value;} function render(test=false){if(document.querySelector('#separator-panel')?.dataset.test===String(test))return; - document.querySelector('#view').innerHTML=`
${test?'STIMMWERKZEUGE':'EINSTELLUNGEN / LAUFZEITEN'}

${test?'Audio trennen':'Audio Separator'}

BS-RoFormer und MelBand RoFormer trennen Gesang und Instrumental. Die native Laufzeit wird einmal installiert und für passende Modellpakete wiederverwendet.

Version 0.47.0 · mindestens 15 GiB frei. Installiert CUDA-PyTorch und Audio Separator in einer eigenen Umgebung. Keine Änderungen an Host-Treibern oder alten Diensten.

Installationsausgabe

Kurzer Trenntest

PCM-WAV, 16 Bit, Mono oder Stereo, bis 30 Sekunden und 8 MiB. Im LLM-Modus koordiniert Deck die GPU-Nutzung und entlädt gegebenenfalls eigene Modelle. Fremde Dienste bleiben unberührt. Das Trennmodell wird nach dem Test entladen.

`; + document.querySelector('#view').innerHTML=`
${test?'KI-WERKZEUGE / AUDIO-TRENNUNG':'EINSTELLUNGEN / LAUFZEITEN'}

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

BS-RoFormer und MelBand RoFormer trennen Gesang und Instrumental. Die native Laufzeit wird einmal installiert und für passende Modellpakete wiederverwendet.

${test?'

Audio-Trennoberfläche öffnen → · auf dem Client ist der SSH-Tunnel für Port 8128 erforderlich.

':''}

Version 0.47.0 · mindestens 15 GiB frei. Installiert CUDA-PyTorch und Audio Separator in einer eigenen Umgebung. Keine Änderungen an Host-Treibern oder alten Diensten.

Installationsausgabe

Kurzer Trenntest

PCM-WAV, 16 Bit, Mono oder Stereo, bis 30 Sekunden und 8 MiB. Im LLM-Modus koordiniert Deck die GPU-Nutzung und entlädt gegebenenfalls eigene Modelle. Fremde Dienste bleiben unberührt. Das Trennmodell wird nach dem Test entladen.

`; const root=document.querySelector('#separator-panel'),msg=root.querySelector('#sep-message');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 poll(){if(!root.isConnected)return;try{const [s,t]=await Promise.all([api('separator-runtime'),api('separator-tests')]);if(!root.isConnected)return; const running=s.job?.state==='running';if(s.job){const log=await api('separator-runtime/log');if(!root.isConnected)return;root.querySelector('#sep-log').textContent=log.text;}root.querySelector('#sep-status').innerHTML=`

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

${s.job?`

${e(s.job.state)} · ${e(s.job.phase)}

${running?'':''}`:''}`; root.querySelector('#sep-install').disabled=pending||running||s.installed;root.querySelector('#sep-cancel').disabled=pending||!running; - const key=JSON.stringify(s.models);if(key!==signature){signature=key;root.querySelector('#sep-models').innerHTML='

Unterstützte Modellpakete

Gewichte und passende Konfiguration werden zusammen beschafft. Diese Pakete sind getrennt von ungeprüften Hugging-Face-Dateien.

'+s.models.map(m=>`

${e(m.name)} · ${m.installed?'Bereit':'Fehlt'}
${e(m.id)} + ${e(m.configuration)}

`).join('');root.querySelector('#sep-model').innerHTML=s.models.filter(m=>m.installed).map(m=>``).join('');root.querySelectorAll('[data-sep-model]').forEach(b=>b.onclick=()=>action('separator-runtime/download',{model:b.dataset.sepModel}));} + const key=JSON.stringify(s.models);if(key!==signature){signature=key;root.querySelector('#sep-models').innerHTML='

Unterstützte Modellpakete

Gewichte und passende Konfiguration werden zusammen beschafft. Diese Pakete sind getrennt von ungeprüften Hugging-Face-Dateien.

'+s.models.map(m=>`

${e(m.name)} · ${m.installed?'Bereit':'Fehlt'}
Quelle: ${e(m.source?.publisher||'Nicht belegt')} · geprüftes Komplettpaket

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

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

`).join("")}

${e(m.id)} + ${e(m.configuration)}

`).join('');root.querySelector('#sep-model').innerHTML=s.models.filter(m=>m.installed).map(m=>``).join('');root.querySelectorAll('[data-sep-model]').forEach(b=>b.onclick=()=>action('separator-runtime/download',{model:b.dataset.sepModel}));} root.querySelectorAll('[data-sep-model]').forEach(b=>b.disabled=pending||running||!s.installed||s.models.find(m=>m.id===b.dataset.sepModel)?.installed); const j=t.job;root.querySelector('#sep-test button').disabled=pending||running||!s.models.some(m=>m.installed)||j?.state==='running';root.querySelector('#sep-test-cancel').disabled=j?.state!=='running';root.querySelector('#sep-job').innerHTML=j?`

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

${j.state==='running'?'':''}`:''; const results=root.querySelector('#sep-results');if(results.dataset.job!==String(j?.id+':'+j?.state)){results.dataset.job=String(j?.id+':'+j?.state);results.innerHTML=j?.state==='complete'?j.files.map(f=>`

${e(f.name)}

Spur herunterladen

`).join(''):'';} diff --git a/separator.py b/separator.py index 864ad78..694afce 100644 --- a/separator.py +++ b/separator.py @@ -8,17 +8,19 @@ class SeparatorTests: 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 status(self): with self.lock:return dict(job=dict(self.job) if self.job else None) - def start(self,model,audio): + def start(self,model,audio,target=None,full=False): + 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.') if not isinstance(model,str) or model not in MODELS or not self.runtime.status()['installed'] or not self.runtime.model_ready(model):raise ValueError('Laufzeit und Modellpaket zuerst unter Audio Separator einrichten.') - if not isinstance(audio,bytes) or not 44<=len(audio)<=8*1024**2:raise ValueError('WAV bis 8 MiB erforderlich.') + if not isinstance(audio,bytes) or not 44<=len(audio)<=(256 if full else 8)*1024**2:raise ValueError('WAV überschreitet die erlaubte Upload-Größe.') try: with wave.open(io.BytesIO(audio)) as wav: - if wav.getnchannels() not in (1,2) or wav.getsampwidth()!=2 or not 8000<=wav.getframerate()<=48000 or not 0deadline:raise ValueError('Trenntest überschreitet zehn Minuten.') + if time.monotonic()>deadline:raise ValueError('Trennauftrag überschreitet das Zeitlimit.') with self.lock:self.job['elapsed_seconds']=round(time.time()-self.job['started_at']) diagnostics.close();error=(directory/'worker.tmp').read_bytes()[-65536:].decode(errors='replace') if self.process.returncode:raise ValueError('GPU-Speicher reicht nicht (OOM).' if 'out of memory' in error.lower() else 'Audio Separator hat den Test abgebrochen. Modellpaket und Laufzeit prüfen.') @@ -67,5 +69,5 @@ class SeparatorTests: if type(index) is not int or not 0<=index32*1024**2:raise ValueError('Spur zu groß.') + if path.stat().st_size>512*1024**2:raise ValueError('Spur zu groß.') return path.read_bytes() diff --git a/separator_runtime.py b/separator_runtime.py index dc9608d..1e03011 100644 --- a/separator_runtime.py +++ b/separator_runtime.py @@ -3,9 +3,22 @@ import hashlib,json,os,shutil,sys,time from pathlib import Path from ltx_original_runtime import LTXOriginalRuntime VERSION='0.47.0' +SOURCES=json.loads(Path(__file__).with_name('separator_sources.json').read_text()) +def package_files(name):return [x['file'] for x in SOURCES[name]['files']] +def verify_package(directory,name): + for item in SOURCES[name]['files']: + path=Path(directory)/item['file'] + if not path.is_file() or path.is_symlink():return False + digest=hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda:stream.read(1024**2),b''):digest.update(chunk) + if digest.hexdigest()!=item['sha256']:return False + return True MODELS={ 'vocals_mel_band_roformer.ckpt':('MelBand RoFormer · Gesang / Instrumental','vocals_mel_band_roformer.yaml'), - 'model_bs_roformer_ep_317_sdr_12.9755.ckpt':('BS-RoFormer · Gesang / Instrumental','model_bs_roformer_ep_317_sdr_12.9755.yaml')} + 'model_bs_roformer_ep_317_sdr_12.9755.ckpt':('BS-RoFormer · Gesang / Instrumental','model_bs_roformer_ep_317_sdr_12.9755.yaml'), + 'htdemucs_ft.yaml':('Demucs FT · Gesang / Schlagzeug / Bass / Rest','htdemucs_ft.yaml'), + 'htdemucs_6s.yaml':('Demucs 6s · zusätzlich Gitarre / Piano','htdemucs_6s.yaml')} class SeparatorRuntime(LTXOriginalRuntime): name='Audio Separator' def __init__(self,root):super().__init__(root,None) @@ -19,8 +32,15 @@ class SeparatorRuntime(LTXOriginalRuntime): return self.root/'missing-python',self.root/'missing-runtime' def status(self): python,base=self.paths() - return dict(installed=python.is_file() and (base/'ready').is_file(),version=VERSION,required_disk_gib=15,job=dict(self.job) if self.job else None,models=[dict(id=name,name=label,configuration=config,installed=self.model_ready(name)) for name,(label,config) in MODELS.items()]) - def model_ready(self,name):return name in MODELS and (self.root/'models'/(name+'.ready')).is_file() and all((self.root/'models'/f).is_file() and (self.root/'models'/f).stat().st_size>0 for f in (name,MODELS[name][1])) + return dict(installed=python.is_file() and (base/'ready').is_file(),version=VERSION,required_disk_gib=15,job=dict(self.job) if self.job else None,models=[dict(id=name,name=label,configuration=config,source=SOURCES[name],installed=self.model_ready(name)) for name,(label,config) in MODELS.items()]) + def model_ready(self,name): + if name not in MODELS:return False + directory=self.root/'models';marker=directory/(name+'.ready') + try: + recorded=json.loads(marker.read_text()) + return recorded=={x['file']:x['sha256'] for x in SOURCES[name]['files']} and all((directory/f).is_file() and not (directory/f).is_symlink() and (directory/f).stat().st_size>0 for f in package_files(name)) + except (OSError,ValueError):return False + def start(self): if not shutil.which('ffmpeg'):raise ValueError('FFmpeg fehlt. Deck-Systemvoraussetzungen installieren.') with self.lock: @@ -41,9 +61,26 @@ class SeparatorRuntime(LTXOriginalRuntime): try: if model: directory=base;directory.mkdir(mode=0o700) - self._command([self.paths()[0],'-c','from audio_separator.separator import Separator; import sys; Separator(model_file_dir=sys.argv[1],info_only=True).download_model_files(sys.argv[2])',directory,model]) - filenames=(model,MODELS[model][1]) - if any(not (directory/f).is_file() or (directory/f).stat().st_size==0 for f in filenames):raise ValueError('Modell oder YAML-Konfiguration fehlt.') + import urllib.request + for item in SOURCES[model]['files']: + self._phase('Modellpaket: '+item['file']) + succeeded=False + for url in item['urls']: + try: + digest=hashlib.sha256();size=0 + with urllib.request.urlopen(url,timeout=60) as response,(directory/item['file']).open('wb') as out: + while chunk:=response.read(1024**2): + if self.cancel.is_set():raise InterruptedError() + size+=len(chunk) + if size>2*1024**3:raise ValueError('Quelldatei zu groß.') + digest.update(chunk);out.write(chunk) + if digest.hexdigest()!=item['sha256']:raise ValueError('Quelldatei stimmt nicht mit dem geprüften Rezept überein.') + succeeded=True;break + except InterruptedError:raise + except Exception:continue + if not succeeded:raise ValueError('Geprüfte Quelle nicht verfügbar: '+item['file']) + filenames=package_files(model) + if not verify_package(directory,model):raise ValueError('Modellpaket-Prüfsumme stimmt nicht.') if self.cancel.is_set():raise InterruptedError() destination=self.root/'models';destination.mkdir(exist_ok=True);hashes={} for f in filenames: diff --git a/separator_sources.json b/separator_sources.json new file mode 100644 index 0000000..c3c1162 --- /dev/null +++ b/separator_sources.json @@ -0,0 +1,113 @@ +{ + "vocals_mel_band_roformer.ckpt": { + "recipe_version": 1, + "publisher": "Kimberley Jensen / UVR", + "homepage": "https://github.com/nomadkaraoke/python-audio-separator", + "files": [ + { + "file": "vocals_mel_band_roformer.ckpt", + "sha256": "87201f4d31afb5bc79993230fc49446918425574db48c01c405e44f365c7559e", + "urls": [ + "https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/vocals_mel_band_roformer.ckpt", + "https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/vocals_mel_band_roformer.ckpt" + ] + }, + { + "file": "vocals_mel_band_roformer.yaml", + "sha256": "b958b29c8f7195f0d86bee6759a33980db675c4ecaf2fcaa80fa125828e6cd38", + "urls": [ + "https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/mdx_model_data/mdx_c_configs/vocals_mel_band_roformer.yaml", + "https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/vocals_mel_band_roformer.yaml" + ] + } + ] + }, + "model_bs_roformer_ep_317_sdr_12.9755.ckpt": { + "recipe_version": 1, + "publisher": "Viperx / UVR", + "homepage": "https://github.com/nomadkaraoke/python-audio-separator", + "files": [ + { + "file": "model_bs_roformer_ep_317_sdr_12.9755.ckpt", + "sha256": "5b84f37e8d444c8cb30c79d77f613a41c05868ff9c9ac6c7049c00aefae115aa", + "urls": [ + "https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/model_bs_roformer_ep_317_sdr_12.9755.ckpt", + "https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/model_bs_roformer_ep_317_sdr_12.9755.ckpt" + ] + }, + { + "file": "model_bs_roformer_ep_317_sdr_12.9755.yaml", + "sha256": "2bfdd16c656bd9519aba757cc4f8834b7ede675eb1e00ec4772d74ae1c41af7f", + "urls": [ + "https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/mdx_model_data/mdx_c_configs/model_bs_roformer_ep_317_sdr_12.9755.yaml", + "https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/model_bs_roformer_ep_317_sdr_12.9755.yaml" + ] + } + ] + }, + "htdemucs_ft.yaml": { + "recipe_version": 1, + "publisher": "Meta / Demucs", + "homepage": "https://github.com/facebookresearch/demucs", + "files": [ + { + "file": "htdemucs_ft.yaml", + "sha256": "69470b8c1bbd674437b51bc9fb491327a10ab0396b702c93389b9cf750016346", + "urls": [ + "https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/htdemucs_ft.yaml", + "https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/htdemucs_ft.yaml" + ] + }, + { + "file": "f7e0c4bc-ba3fe64a.th", + "sha256": "ba3fe64ae8ef66ac9a4857222ce48efbdc5eb3ad375cb79dd13debee5aaa4066", + "urls": [ + "https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/f7e0c4bc-ba3fe64a.th" + ] + }, + { + "file": "d12395a8-e57c48e6.th", + "sha256": "e57c48e6b0e38af4f7118d7bd08c49f0a0c0edf7d09143bdd902ea0d237303e6", + "urls": [ + "https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/d12395a8-e57c48e6.th" + ] + }, + { + "file": "92cfc3b6-ef3bcb9c.th", + "sha256": "ef3bcb9c8b40d14ae5d51b6db2587339cc12c6b77c0be151ce6d69002e087bf2", + "urls": [ + "https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/92cfc3b6-ef3bcb9c.th" + ] + }, + { + "file": "04573f0d-f3cf25b2.th", + "sha256": "f3cf25b222c4eed7cd49dd8b2c9597d50c18bd154090f7b919cfa5f93cf22c49", + "urls": [ + "https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/04573f0d-f3cf25b2.th" + ] + } + ] + }, + "htdemucs_6s.yaml": { + "recipe_version": 1, + "publisher": "Meta / Demucs", + "homepage": "https://github.com/facebookresearch/demucs", + "files": [ + { + "file": "htdemucs_6s.yaml", + "sha256": "207405151270af8fd81c2373c25d27950916682ac91dca7884a11ce13dad6f58", + "urls": [ + "https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/htdemucs_6s.yaml", + "https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/htdemucs_6s.yaml" + ] + }, + { + "file": "5c90dfd2-34c22ccb.th", + "sha256": "34c22ccb381c6f9fdbf324f04e1e2fe21aaaf293f5ded163a162697ff9a02ddd", + "urls": [ + "https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/5c90dfd2-34c22ccb.th" + ] + } + ] + } +} diff --git a/separator_worker.py b/separator_worker.py index 684fc6a..d1c52a3 100644 --- a/separator_worker.py +++ b/separator_worker.py @@ -3,9 +3,12 @@ import json,sys from pathlib import Path from audio_separator.separator import Separator import torch -model_dir,model,input_file,output_dir=sys.argv[1:] +model_dir,model,input_file,output_dir=sys.argv[1:5] +target=sys.argv[5] if len(sys.argv)>5 else '' +from separator_runtime import verify_package +if not verify_package(model_dir,model):raise RuntimeError('Modellpaket-Prüfsumme stimmt nicht.') if not torch.cuda.is_available():raise RuntimeError('CUDA nicht verfügbar; kein stiller CPU-Fallback.') -separator=Separator(model_file_dir=model_dir,output_dir=output_dir,output_format='WAV',use_soundfile=True,mdxc_params={'segment_size':256,'override_model_segment_size':False,'batch_size':1,'overlap':2,'pitch_shift':0}) +separator=Separator(model_file_dir=model_dir,output_dir=output_dir,output_format='WAV',use_soundfile=True,demucs_params={'segment_size':7,'shifts':2,'overlap':.25},mdxc_params={'segment_size':256,'override_model_segment_size':False,'batch_size':1,'overlap':2,'pitch_shift':0}) separator.load_model(model) files=separator.separate(input_file) root=Path(output_dir).resolve();names=[] @@ -14,4 +17,15 @@ for name in files: if path.parent!=root or not path.is_file() or path.suffix.lower()!='.wav':raise RuntimeError('Ungültige Ausgabe.') names.append(path.name) if not names:raise RuntimeError('Keine Spuren erzeugt.') +if target: + import subprocess + chosen=next((n for n in names if target in Path(n).stem.lower()),None) + if not chosen:raise RuntimeError('Zielspur fehlt.') + others=[root/n for n in names if n!=chosen] + if not others:raise RuntimeError('Restspuren fehlen.') + args=['ffmpeg','-hide_banner','-loglevel','error','-y'] + for f in others:args+=['-i',str(f)] + args+=['-filter_complex',''.join(f'[{i}:a]' for i in range(len(others)))+f'amix=inputs={len(others)}:normalize=0:dropout_transition=0[rest]','-map','[rest]','-c:a','pcm_s16le',str(root/'rest.wav')] + subprocess.run(args,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=180) + names=[chosen,'rest.wav'] (root/'results.json').write_text(json.dumps(names)) diff --git a/server.py b/server.py index 61f8262..d5cb95f 100644 --- a/server.py +++ b/server.py @@ -187,7 +187,7 @@ class Handler(BaseHTTPRequestHandler): return secrets.compare_digest(actual,record['api_token_hash']) def token_route(self): - return self.command == 'GET' and self.path in ('/api/v1/status','/api/v1/hardware') + return (self.command == 'GET' and (self.path in ('/api/v1/status','/api/v1/hardware','/api/v1/separator-runtime','/api/v1/separator-tests') or urlsplit(self.path).path=='/api/v1/separator-tests/audio')) or (self.command=='POST' and self.path in ('/api/v1/separator-jobs/start','/api/v1/separator-tests/cancel')) def session_token(self): try: @@ -547,15 +547,15 @@ class Handler(BaseHTTPRequestHandler): if data:raise ValueError('Keine Parameter erwartet.') return self.respond(self.server.separator_runtime.stop() if self.path.endswith('/cancel') else self.server.separator_runtime.start()) except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400) - if self.path in ('/api/v1/separator-tests/start','/api/v1/separator-tests/cancel'): + if self.path in ('/api/v1/separator-tests/start','/api/v1/separator-jobs/start','/api/v1/separator-tests/cancel'): try: if self.path.endswith('/cancel'): if self.read_json():raise ValueError('Keine Parameter erwartet.') return self.respond(self.server.separator_tests.stop()) # Reuse bounded multipart parsing; separation permits stereo WAV. - fields,audio=read_upload(self,validate=False) - if set(fields)!={'model'}:raise ValueError('Nur Modell und WAV-Datei erwartet.') - return self.respond(self.server.separator_tests.start(fields['model'],audio)) + fields,audio=read_upload(self,validate=False,max_audio=256*1024**2 if self.path=='/api/v1/separator-jobs/start' else 8*1024**2) + if not {'model'}<=set(fields) or set(fields)-{'model','target'}:raise ValueError('Nur Modell, Zielspur und WAV-Datei erwartet.') + return self.respond(self.server.separator_tests.start(fields['model'],audio,target=fields.get('target'),full=self.path=='/api/v1/separator-jobs/start')) except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400) if self.path in ('/api/v1/audio-cpp-runtime/install','/api/v1/audio-cpp-runtime/cancel'): try: diff --git a/stt.py b/stt.py index 8730e68..9d93d59 100644 --- a/stt.py +++ b/stt.py @@ -22,13 +22,13 @@ def validate_wav(audio): if len(w.readframes(w.getnframes()))!=w.getnframes()*2:raise ValueError('WAV-Datei ist unvollständig.') except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei.') from None -def read_upload(handler,validate=True): +def read_upload(handler,validate=True,max_audio=MAX_AUDIO): handler.connection.settimeout(30) if handler.headers.get('Transfer-Encoding'):raise ValueError('Chunked Upload wird nicht unterstützt.') try:length=int(handler.headers.get('Content-Length','0')) except ValueError:raise ValueError('Ungültige Upload-Länge.') from None content_type=handler.headers.get('Content-Type','') - if not 0256:raise ValueError('Ungültiges oder doppeltes Feld.') + if name not in ('model','profile_id','language','response_format','target') or name in fields or len(data)>256:raise ValueError('Ungültiges oder doppeltes Feld.') try:fields[name]=data.decode('utf-8') except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None if validate:validate_wav(audio) diff --git a/test_separator.py b/test_separator.py index 944f249..8b413e4 100644 --- a/test_separator.py +++ b/test_separator.py @@ -19,7 +19,9 @@ class Tests(unittest.TestCase): def test_model_requires_configuration_and_unknown_download_rejected(self): with tempfile.TemporaryDirectory() as root: runtime=SeparatorRuntime(root);models=Path(root,'models');models.mkdir();name=next(iter(MODELS));(models/name).write_bytes(b'weights') - self.assertFalse(runtime.model_ready(name));(models/MODELS[name][1]).write_text('model: {}');(models/(name+'.ready')).write_text('{}');self.assertTrue(runtime.model_ready(name)) + self.assertFalse(runtime.model_ready(name));(models/MODELS[name][1]).write_text('model: {}');(models/(name+'.ready')).write_text('{}');self.assertFalse(runtime.model_ready(name)) + from separator_runtime import SOURCES + (models/(name+'.ready')).write_text(json.dumps({f['file']:f['sha256'] for f in SOURCES[name]['files']}));self.assertTrue(runtime.model_ready(name)) with self.assertRaises(ValueError):runtime.download('../other') with self.assertRaises(ValueError):runtime.download([]) def test_stereo_accepted_and_long_or_truncated_wav_rejected(self): diff --git a/test_separator_sources.py b/test_separator_sources.py new file mode 100644 index 0000000..4bab521 --- /dev/null +++ b/test_separator_sources.py @@ -0,0 +1,19 @@ +import hashlib,json,tempfile,unittest +from pathlib import Path +from unittest.mock import patch +from separator_runtime import SOURCES,MODELS,verify_package,package_files +class Tests(unittest.TestCase): + def test_demucs_ensemble_complete_and_sources_https(self): + self.assertEqual(set(SOURCES),set(MODELS)) + self.assertEqual(len(package_files('htdemucs_ft.yaml')),5) + self.assertEqual(len(package_files('htdemucs_6s.yaml')),2) + for recipe in SOURCES.values(): + for f in recipe['files']: + self.assertEqual(len(f['sha256']),64) + self.assertTrue(all(u.startswith('https://') for u in f['urls'])) + def test_wrong_or_missing_weight_rejected(self): + with tempfile.TemporaryDirectory() as d: + recipe={'x':{'files':[{'file':'weight','sha256':hashlib.sha256(b'valid').hexdigest()}]}} + with patch.dict(SOURCES,recipe): + self.assertFalse(verify_package(d,'x'));Path(d,'weight').write_bytes(b'wrong');self.assertFalse(verify_package(d,'x')) + Path(d,'weight').write_bytes(b'valid');self.assertTrue(verify_package(d,'x'))