Add audio separation web app and verified recovery sources
This commit is contained in:
1 parent
b5ee7c1e01
commit
433e6709cf
25 files changed
+381
-46
No files matched your search
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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 0<length<=256*1024**2:raise ValueError('Upload maximal 256 MiB.')
|
||||
self.connection.settimeout(60);raw=self.rfile.read(length)
|
||||
if len(raw)!=length:raise ValueError('Upload unvollständig.')
|
||||
if self.path=='/api/connection':
|
||||
if length>4096: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()<deadline:
|
||||
time.sleep(2);current=json.loads(request('/api/v1/separator-tests'))['job']
|
||||
if not current or current['id']!=ident:raise ValueError('Auftrag wurde ersetzt.')
|
||||
if current['state']=='running':continue
|
||||
if current['state']!='complete':raise ValueError(current['phase'])
|
||||
result=io.BytesIO()
|
||||
with zipfile.ZipFile(result,'w',zipfile.ZIP_STORED) as bundle:
|
||||
for f in current['files']:bundle.writestr(Path(f['name']).name,request(f"/api/v1/separator-tests/audio?id={ident}&index={f['index']}"))
|
||||
return self.send(result.getvalue(),mime='application/zip')
|
||||
raise ValueError('Trennauftrag hat das Zeitlimit überschritten.')
|
||||
except Exception as exc:return self.send({'detail':str(exc) if isinstance(exc,ValueError) else 'Verarbeitung fehlgeschlagen; Verbindung und Audioformat prüfen.'},400)
|
||||
if __name__=='__main__':ThreadingHTTPServer(('127.0.0.1',8128),Handler).serve_forever()
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Athena · Spuren herauslösen</title><style>
|
||||
:root{color-scheme:dark;--bg:#07111c;--card:#101d2b;--line:#26384b;--cyan:#48d7f5;--mint:#63e6be;--text:#ecf5ff;--muted:#91a4b7}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#142a42 0,#07111c 42%);font:16px system-ui,sans-serif;color:var(--text);min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(880px,100%);padding:32px;border:1px solid var(--line);border-radius:22px;background:rgba(16,29,43,.96);box-shadow:0 25px 70px #0008}.eyebrow{color:var(--cyan);font-weight:800;letter-spacing:.14em;text-transform:uppercase;font-size:12px}h1{font-size:clamp(30px,5vw,52px);margin:.3em 0 .15em}p{color:var(--muted);line-height:1.6}.targets{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin:24px 0}.target{display:block;border:1px solid var(--line);border-radius:14px;padding:14px 10px;text-align:center;cursor:pointer}.target:has(input:checked){border-color:var(--cyan);background:#48d7f510;box-shadow:0 0 0 1px #48d7f528}.target input{display:none}.target b,.target span{display:block}.target span{color:var(--muted);font-size:12px;margin-top:5px;line-height:1.35}.section{grid-column:1/-1;color:var(--cyan);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;margin-top:8px}.drop{display:block;margin:20px 0;padding:36px 24px;border:2px dashed #3f5a72;border-radius:18px;text-align:center;cursor:pointer;transition:.2s}.drop:hover,.drop.drag{border-color:var(--cyan);background:#48d7f50b}.drop input{display:none}.file{color:var(--mint);font-weight:700;margin-top:8px}button{width:100%;border:0;border-radius:13px;padding:15px;font-weight:800;font-size:16px;background:linear-gradient(90deg,var(--cyan),var(--mint));color:#05202a;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.status{min-height:28px;margin-top:18px;color:var(--muted)}.bar{height:7px;background:#07111c;border-radius:9px;overflow:hidden;margin-top:12px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--mint));transition:.4s}.run .fill{width:85%;animation:pulse 1.5s infinite alternate}@keyframes pulse{to{opacity:.45}}small{display:block;color:#71879a;margin-top:20px}@media(max-width:760px){.targets{grid-template-columns:repeat(2,1fr)}}
|
||||
</style></head><body><main class="card"><div class="eyebrow">Athena Audio Lab</div><h1>Was möchtest du herauslösen?</h1><p>Der Download enthält immer die gewählte Spur separat und zusätzlich den vollständigen Rest ohne diese Spur.</p>
|
||||
<details><summary>Deck-Verbindung einrichten</summary><p>API-Token aus Athena Deck → Zugang & API-Token. Er bleibt nur auf dem Server gespeichert.</p><input id="token" type="password" autocomplete="off" placeholder="Deck API-Token"><button id="connect">Verbinden</button></details><p id="packages">Modellpakete werden geprüft …</p><div class="targets">
|
||||
<div class="section">Musik</div>
|
||||
<label class="target"><input type="radio" name="target" value="vocals" checked><b>Gesang</b><span>BS‑RoFormer<br>Gesang / Instrumental</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="drums"><b>Schlagzeug</b><span>HTDemucs FT</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="bass"><b>Bass</b><span>HTDemucs FT</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="guitar"><b>Gitarre</b><span>HTDemucs 6s<br>experimentell</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="piano"><b>Piano</b><span>HTDemucs 6s<br>experimentell</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="other"><b>Sonstiges</b><span>Synths, Streicher etc.<br>gemischte Spur</span></label>
|
||||
|
||||
|
||||
</div>
|
||||
<label class="drop" id="drop">Audio auswählen oder hier ablegen<input id="file" type="file" accept="audio/*"><div class="file" id="name">Noch keine Datei gewählt</div></label><button id="start" disabled>Ausgewählte Spur und Rest erzeugen</button><div class="status" id="status">Bereit.</div><div class="bar" id="bar"><div class="fill"></div></div><small>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.</small></main><script>
|
||||
const file=document.querySelector('#file'),drop=document.querySelector('#drop'),name=document.querySelector('#name'),start=document.querySelector('#start'),status=document.querySelector('#status'),bar=document.querySelector('#bar');let selected;async function packages(){let r=await fetch('/api/status'),s=await r.json();document.querySelector('#packages').textContent=r.ok?s.models.map(m=>`${m.name}: ${m.installed?'bereit':'fehlt – in Deck herunterladen'}`).join(' · '):s.detail}document.querySelector('#connect').onclick=async()=>{let token=document.querySelector('#token');let r=await fetch('/api/connection',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-UI':'1'},body:JSON.stringify({token:token.value})});let s=await r.json();token.value='';status.textContent=r.ok?'Deck verbunden.':s.detail;packages()};packages();const names={vocals:'gesang',drums:'schlagzeug',bass:'bass',guitar:'gitarre',piano:'piano',other:'sonstiges',speech:'sprache-und-hintergrund'};function choose(f){selected=f;name.textContent=f?`${f.name} · ${(f.size/1048576).toFixed(1)} MiB`:'Noch keine Datei gewählt';start.disabled=!f}file.onchange=()=>choose(file.files[0]);drop.ondragover=e=>{e.preventDefault();drop.classList.add('drag')};drop.ondragleave=()=>drop.classList.remove('drag');drop.ondrop=e=>{e.preventDefault();drop.classList.remove('drag');choose(e.dataTransfer.files[0])};start.onclick=async()=>{const target=document.querySelector('input[name=target]:checked').value;start.disabled=true;bar.classList.add('run');status.textContent=target==='speech'?'MossFormer2 trennt Sprache und Hintergrund – das kann einige Minuten dauern …':'Modell löst die gewählte Spur heraus – das kann einige Minuten dauern …';let body=new FormData();body.append('file',selected);body.append('target',target);try{let r=await fetch('/v1/separate',{method:'POST',headers:{'X-Athena-UI':'1'},body});if(!r.ok)throw Error((await r.json()).detail||`HTTP ${r.status}`);let blob=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`athena-${names[target]}-und-rest.zip`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status.textContent='Fertig – ZIP mit der ausgewählten Spur und dem Rest wurde geladen.'}catch(e){status.textContent=`Fehler: ${e.message}`}finally{bar.classList.remove('run');start.disabled=false}};
|
||||
</script></body></html>
|
||||
Reference in new issue
Block a user