From 3aa2301a5382b8987065d77ca20137347b8f303d Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:13:31 +0200 Subject: [PATCH] Add exclusive music GPU mode beside LLM and video --- deploy/install.py | 2 +- docs/MUSIC_API.md | 4 ++++ endpoint.py | 4 +++- inference.py | 6 +++--- music-ui.js | 2 +- music.py | 5 +++-- server.py | 3 ++- test_endpoint.py | 3 +++ test_music.py | 2 +- test_video_comfy.py | 12 ++++++++++++ video-ui.js | 2 +- video_comfy.py | 17 ++++++++++++----- 12 files changed, 46 insertions(+), 16 deletions(-) diff --git a/deploy/install.py b/deploy/install.py index 776f044..65ec1d9 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -204,7 +204,7 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') stamp=str(time.time_ns()) - shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('music-jobs','audio-cpp-runtime','ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*')) + shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('music-verification','music-test-temp','music-jobs','audio-cpp-runtime','ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*')) was_running=previous['State']['Running'] if was_running:run('docker','stop','--time','15',config['name']) run('docker','rename',config['name'],backup_name) diff --git a/docs/MUSIC_API.md b/docs/MUSIC_API.md index b9d6019..3c7cf4e 100644 --- a/docs/MUSIC_API.md +++ b/docs/MUSIC_API.md @@ -23,3 +23,7 @@ Die Ladypoly-WebUI ist hiermit noch nicht verbunden. Der Deck-Testbereich und di ## Verifikation auf Athena 2026-09-30: YuE2 BF16 mit zugeordnetem F16-VAE und Sidecars erzeugte bei 200 semantischen Tokens / 2 NAR-Schritten eine gültige stereo WAV von 8 Sekunden in etwa 18 Sekunden auf der RTX 5080. Danach Prozess beendet und GPU-Speicher freigegeben. Dies prüft Ausführung und Dateiformat, nicht musikalische Qualität oder Stabilität langer Aufträge. + +## Exklusiver Musik-Modus + +Übersicht → GPU-Modus bietet LLM, Video und Musik. Musik setzt mindestens ein am Endpunkt freigegebenes ausführbares Musikprofil voraus. Beim Wechsel werden eigene Deck-GPU-Worker beendet und laufende GPU-Reservierungen freigegeben; fremde Videodienste werden geprüft, aber nie gestoppt. Musikgewichte laden weiterhin erst bei Anfrage. Im Musikmodus werden Chat/Bild/TTS/STT blockiert; für diese Aufträge auf LLM zurückschalten. Musik → Testen zeigt einen Umschalthinweis, solange ein anderer Modus aktiv ist. diff --git a/endpoint.py b/endpoint.py index 7183525..ea62935 100644 --- a/endpoint.py +++ b/endpoint.py @@ -209,7 +209,8 @@ class APIHandler(BaseHTTPRequestHandler): if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'}) if self.video_route(ep):return if self.command!='POST':raise APIError('Route nicht gefunden.',404) - if ep.video and ep.scheduler.gpu_mode!='llm' and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active') + if ep.scheduler.gpu_mode=='music' and self.path!='/v1/audio/music':raise APIError('Musik-Modus aktiv. Für andere KI-Aufträge auf LLM wechseln.',503,'music_mode_active') + if ep.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active') if self.path=='/v1/audio/transcriptions':return self.transcription(ep) if self.path=='/v1/images/edits': from image_upload import read_image_upload @@ -275,6 +276,7 @@ class APIHandler(BaseHTTPRequestHandler): self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);return raise APIError('TTS-Zeitlimit überschritten.',504) def music_generation(self,ep,data): + if ep.scheduler.gpu_mode!='music':raise APIError('Bitte in der Übersicht auf Musik umschalten.',503,'music_mode_required') if not ep.music:raise APIError('Musikworker nicht eingerichtet.',501) if set(data)-{'model','lyrics','style','seed','max_tokens','steps'}:raise APIError('Nicht unterstützte Musikfelder.') profile=ep.find_profile(data.get('model'),'music') diff --git a/inference.py b/inference.py index 963633b..39155e3 100644 --- a/inference.py +++ b/inference.py @@ -23,14 +23,14 @@ class Scheduler: def status(self): with self.cv:return dict(active_requests=self.active+self.tts_active,waiting_requests=len(self.queue),switching=self.transition) @contextlib.contextmanager - def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True): + def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True, modes=("llm",)): ticket=object();deadline=time.monotonic()+timeout;claimed=False with self.cv: if len(self.queue)>=16:raise InferenceError('Warteschlange voll. Bitte später erneut versuchen.') self.queue.append(ticket) try: while True: - if self.gpu_mode!="llm":raise InferenceError("Video-Modus aktiv oder Moduswechsel läuft; GPU-Aufträge sind gesperrt.") + if self.gpu_mode not in modes:raise InferenceError("Video-Modus aktiv oder Moduswechsel läuft; GPU-Aufträge sind gesperrt.") if not allowed():raise InferenceError('Endpunkt wird gestoppt oder Profil ist nicht mehr aktiviert.') if self.queue[0] is ticket and not self.transition and (not self.active or (self.key==key and self.active{ async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const x=await r.json();if(!r.ok)throw Error(x.error||'Musikanfrage fehlgeschlagen');return x;} function html(){return '

Musik ausprobieren

YuE2 über audio.cpp. Englische Songtexte werden empfohlen. Ein kurzer Test beginnt mit höchstens 400 semantischen Tokens; dies ist keine feste Dauer in Sekunden.

Testparameter

RTX 5080 bevorzugt. Deck-Aufträge werden koordiniert. Nach der Anfrage wird das Modell entladen. Text und Stil werden nicht dauerhaft gespeichert.

';} function bind(){const root=document.querySelector('#music-test'),el=id=>root.querySelector('#'+id);let ready=false,busy=false; - async function poll(){if(!root.isConnected)return;try{const s=await api('music');if(!root.isConnected)return;const j=s.job,running=j?.state==='running';el('music-state').textContent=(j?.phase||'Profil wählen und Musikstil sowie Songtext eingeben.')+(running?' · '+Math.round((Date.now()/1000)-j.started_at)+' s':'');el('music-progress').innerHTML=running?'':'';el('music-start').disabled=busy||running||!ready;el('music-cancel').disabled=!running;if(j?.state==='complete')el('music-result').innerHTML=`

Ergebnis · ${e(j.duration_seconds)} Sekunden

WAV herunterladen

`;if(j?.state==='failed')el('music-error').textContent=j.phase;}catch(x){if(root.isConnected)el('music-error').textContent=x.message;}if(root.isConnected)setTimeout(poll,1500);} + async function poll(){if(!root.isConnected)return;try{const s=await api('music');if(!root.isConnected)return;const j=s.job,running=j?.state==='running';el('music-state').textContent=(s.mode!=='music'?'Bitte in der Übersicht auf Musik umschalten.':j?.phase||'Profil wählen und Musikstil sowie Songtext eingeben.')+(running?' · '+Math.round((Date.now()/1000)-j.started_at)+' s':'');el('music-progress').innerHTML=running?'':'';el('music-start').disabled=busy||running||!ready||s.mode!=='music';el('music-cancel').disabled=!running;if(j?.state==='complete')el('music-result').innerHTML=`

Ergebnis · ${e(j.duration_seconds)} Sekunden

WAV herunterladen

`;if(j?.state==='failed')el('music-error').textContent=j.phase;}catch(x){if(root.isConnected)el('music-error').textContent=x.message;}if(root.isConnected)setTimeout(poll,1500);} api('profiles').then(s=>{if(!root.isConnected)return;const profiles=s.profiles.filter(p=>p.kind==='music'&&p.runnable);ready=profiles.length>0;el('music-profile').innerHTML=profiles.map(p=>``).join('')||'';}).catch(x=>el('music-error').textContent=x.message); el('music-form').onsubmit=async event=>{event.preventDefault();busy=true;el('music-start').disabled=true;el('music-error').textContent='';el('music-result').replaceChildren();try{await api('music/start',{profile_id:el('music-profile').value,lyrics:el('music-lyrics').value,style:el('music-style').value,seed:Number(el('music-seed').value),max_tokens:Number(el('music-tokens').value),steps:Number(el('music-steps').value)});}catch(x){el('music-error').textContent=x.message;}finally{busy=false;}};el('music-cancel').onclick=()=>api('music/cancel',{}).catch(x=>el('music-error').textContent=x.message);poll();} return {html,bind}; diff --git a/music.py b/music.py index 371f3e1..1c1a4b4 100644 --- a/music.py +++ b/music.py @@ -14,12 +14,13 @@ class Music: except ValueError:result.append(info['label']+' fehlt oder ist nicht zugeordnet.') return result def status(self): - with self.lock:return dict(job=dict(self.job) if self.job else None,loaded=bool(self.process and self.process.poll() is None),runtime_installed=self.runtime.status()['installed']) + with self.lock:return dict(job=dict(self.job) if self.job else None,loaded=bool(self.process and self.process.poll() is None),mode=self.scheduler.gpu_mode,runtime_installed=self.runtime.status()['installed']) def start(self,profile_id,lyrics,style,seed=1234,max_tokens=400,steps=8,wait=False): if not isinstance(lyrics,str) or not 1<=len(lyrics.strip())<=8000:raise ValueError('Songtext mit 1–8000 Zeichen eingeben.') if not isinstance(style,str) or not 1<=len(style.strip())<=2000:raise ValueError('Musikstil mit 1–2000 Zeichen eingeben.') for value,lo,hi in ((seed,0,2**31-1),(max_tokens,32,9000),(steps,1,32)): if type(value) is not int or not lo<=value<=hi:raise ValueError('Ungültige Musikparameter.') + if self.scheduler.gpu_mode!='music':raise ValueError('Bitte in der Übersicht auf Musik umschalten.') with self.lock: if self.job and self.job['state']=='running':raise ValueError('Musikauftrag läuft bereits.') p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='music'),None) @@ -30,7 +31,7 @@ class Music: def _run(self,p,lyrics,style,seed,max_tokens,steps,wait): release=None;state='failed';phase='Musikgenerierung fehlgeschlagen.';directory=self.root/self.job['id'];log=directory/'worker.tmp' try: - release=self.scheduler.image_reservation(wait=wait) + release=self.scheduler.image_reservation(wait=wait,kind="music") if self.cancel.is_set():raise InterruptedError() required=int(p['model']['size']/1024**2)+1800 gpu=next((g for g in probe() if 'RTX 5080' in g['name'] and not g['processes'] and g['free_mib']>required),None) diff --git a/server.py b/server.py index 9c7dc7d..a2dd586 100644 --- a/server.py +++ b/server.py @@ -127,6 +127,7 @@ class Server(ThreadingHTTPServer): self.ltx_original_runtime=LTXOriginalRuntime(self.catalog.root.parent/'ltx-original-runtime',self.image_runtime) self.video=VideoRuntimes(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime,original_runtime=self.ltx_original_runtime) self.endpoint.video=self.video + self.video.music_profiles=lambda:[dict(id=p['id'],name=p['name']) for p in self.endpoint.rows() if p['kind']=='music' and p['enabled'] and p['runnable']] self.profiles.video_runtime_ready=lambda:self.image_runtime.status().get("installed",False) from dashboard_data import Dashboard self.dashboard=Dashboard(self,self.catalog.root.parent) @@ -520,7 +521,7 @@ class Handler(BaseHTTPRequestHandler): data=self.read_json() if self.path.endswith('/runtime') and set(data)=={'runtime'}:return self.respond(self.server.video.select_runtime(data['runtime'])) if self.path.endswith('/service') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id'])) - if set(data)!={'mode'}:raise ValueError('Nur Modus llm oder video erwartet.') + if set(data)!={'mode'}:raise ValueError('Nur Modus llm, video oder music erwartet.') return self.respond(self.server.video.switch(data['mode'])) except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400) if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'): diff --git a/test_endpoint.py b/test_endpoint.py index 55a7be2..ccc0f97 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -67,6 +67,9 @@ class EndpointTests(unittest.TestCase): self.enable('music') self.assertEqual(self.request('/v1/audio/music/models')[1]['data'][0]['id'],'Music') self.assertNotIn('Music',[x['id'] for x in self.request()[1]['data']]) + self.assertEqual(self.request('/v1/audio/music',dict(model='Music',lyrics='Synthetic',style='pop'))[0],503) + self.ep.scheduler.gpu_mode='music' + self.assertEqual(self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='Synthetic')]))[0],503) conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5) conn.request('POST','/v1/audio/music',json.dumps(dict(model='Music',lyrics='Synthetic',style='pop')),{'Content-Type':'application/json','Authorization':'Bearer '+'A'*32}) response=conn.getresponse();self.assertEqual(response.status,200);self.assertEqual(response.getheader('Content-Type'),'audio/wav');self.assertEqual(response.read(),b'RIFFsynthetic-WAV');conn.close() diff --git a/test_music.py b/test_music.py index d669185..84220d2 100644 --- a/test_music.py +++ b/test_music.py @@ -15,7 +15,7 @@ class MusicTests(unittest.TestCase): profiles=Mock();profiles.status.return_value=dict(profiles=[p]);profiles._component.side_effect=lambda model,role,id:entries[id] cli=root/'cli';cli.write_text('#!/usr/bin/env python3\nimport sys,wave\np=sys.argv[sys.argv.index("--out")+1]\nwith wave.open(p,"wb") as w:w.setparams((1,2,8000,0,"NONE","none"));w.writeframes(bytes(1600))\n');cli.chmod(0o755) runtime=Mock();runtime.status.return_value=dict(installed=True);runtime.paths.return_value=(cli,root) - scheduler=Mock();release=Mock();scheduler.image_reservation.return_value=release + scheduler=Mock();scheduler.gpu_mode='music';release=Mock();scheduler.image_reservation.return_value=release return Music(root/'jobs',profiles,catalog,runtime,scheduler),release,p def wait(self,m): for _ in range(80): diff --git a/test_video_comfy.py b/test_video_comfy.py index 9e12727..8a545bb 100644 --- a/test_video_comfy.py +++ b/test_video_comfy.py @@ -44,3 +44,15 @@ class VideoComfyTests(unittest.TestCase): self.assertTrue(self.video.service_authenticated('Bearer synthetic-service-secret')) self.assertFalse(self.video.service_authenticated('Bearer wrong')) self.assertFalse(self.video.service_authenticated('Bearer ü')) + + def test_music_switch_is_exclusive_and_returns_to_llm(self): + import threading + from types import SimpleNamespace + scheduler=SimpleNamespace(gpu_mode='llm',cv=threading.Condition(),active=0,tts_active=0,transition=False,key=('chat',)) + self.video.scheduler=scheduler;self.video._stop=Mock();self.video._foreign_services=lambda:[] + self.video.music_profiles=lambda:[dict(id='music',name='YuE2')] + self.video.switch('music');self.video.thread.join(2) + self.assertEqual(scheduler.gpu_mode,'music');self.assertEqual(self.video.state,'ready');self.video.stop_owned.assert_called_once();self.video._stop.assert_called_once() + self.video.switch('llm');self.video.thread.join(2);self.assertEqual(scheduler.gpu_mode,'llm') + def test_music_requires_enabled_ready_profile(self): + with self.assertRaisesRegex(ValueError,'Musikprofil'):self.video.switch('music') diff --git a/video-ui.js b/video-ui.js index a36ef6b..0180261 100644 --- a/video-ui.js +++ b/video-ui.js @@ -3,7 +3,7 @@ window.VideoUI=(()=>{ async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;} function mode(root,s,profiles=[],port=null){ if(root.dataset.busy==='1')return; - root.innerHTML=`

GPU-Modus

Gemeinsamer API-Port: ${e(port||"siehe Endpunkt")} · LLM-Modus: OpenAI-API · Video-Modus: ${s.runtime_kind==='original'?'originale LTX-API':'ComfyUI-API'}

${e(s.service?.name||'Kein Videodienst eingerichtet')}

${s.state==='switching'?`
${e(s.switch_phase)}
`:`

${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}

`}${s.service?.api_url?`

Backend: ${e(s.service.api_url)}

${e(s.service.message)}

${s.service.ready&&port?`${s.runtime_kind==='original'?'LTX DeskWEB öffnen':'ComfyUI öffnen'} →`:""}`:''}${s.error?``:''}

Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.

Aktiven Videodienst im Bereich Video wählen
`; + root.innerHTML=`

GPU-Modus

Gemeinsamer API-Port: ${e(port||"siehe Endpunkt")} · LLM-Modus: OpenAI-API · Video-Modus: ${s.runtime_kind==='original'?'originale LTX-API':'ComfyUI-API'}

${e(s.mode==='music'?s.music_profiles?.map(p=>p.name).join(', '):s.service?.name||'Kein Videodienst eingerichtet')}

${s.state==='switching'?`
${e(s.switch_phase)}
`:`

${s.mode==='music'?'Musik bereit · Gewichte laden bei Anfrage':s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}

`}${s.mode!=='music'&&s.service?.api_url?`

Backend: ${e(s.service.api_url)}

${e(s.service.message)}

${s.service.ready&&port?`${s.runtime_kind==='original'?'LTX DeskWEB öffnen':'ComfyUI öffnen'} →`:""}`:''}${s.error?``:''}

Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.

Aktiven Videodienst im Bereich Video wählen${!s.music_profiles?.length?'

Für Musik zuerst unter Musik → Profile ein ausführbares Profil am API-Endpunkt freigeben.

':''}
`; root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next,profiles,port);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}}); } function services(root){ diff --git a/video_comfy.py b/video_comfy.py index e9648ce..c5763ba 100644 --- a/video_comfy.py +++ b/video_comfy.py @@ -55,11 +55,12 @@ class VideoComfy: self.state='failed';self.error='ComfyUI wurde unerwartet beendet. Auf LLM wechseln und Laufzeit erneut starten.' rows=self.models();ident=self.selection.get('model_id');row=next((x for x in rows if x['model']['id']==ident),None) service=dict(id=ident,name=row['model']['file'] if row else 'Kein Videomodell ausgewählt',running=running,ready=running and self.state=='ready',api_url='/ (ComfyUI auf dem gemeinsamen API-Port)',message='5080: Diffusion/VAE · 3060: Textencoder · große BF16-Gewichte nutzen CPU-Auslagerung.') if row else {} - return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status()) + return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,music_profiles=getattr(self,'music_profiles',lambda:[])(),services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status()) def switch(self,mode): - if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.') + if mode not in ('llm','video','music'):raise ValueError('Modus muss llm, video oder music sein.') with self.lock: if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.') + if mode=='music' and not getattr(self,'music_profiles',lambda:[])():raise ValueError('Zuerst ein ausführbares Musikprofil am API-Endpunkt freigeben.') if mode=='video': row=next((x for x in self.models() if x['model']['id']==self.selection.get('model_id')),None) if not row:raise ValueError('Zuerst Videomodell aktivieren.') @@ -70,8 +71,13 @@ class VideoComfy: return self.status() def _switch(self,mode): try: - if mode=='llm': - self._stop() + self.stop_owned();self._stop();deadline=time.monotonic()+60 + with self.scheduler.cv: + while self.scheduler.active or self.scheduler.tts_active or self.scheduler.transition: + if time.monotonic()>deadline:raise ValueError('Deck-Aufträge noch aktiv.') + self.scheduler.cv.wait(.2) + self.scheduler.key=None + if mode in ('llm','music'): if any(x.get('running') is not False for x in self._foreign_services()):raise ValueError('Fremder Videodienst blockiert LLM-Modus.') self.foreign_blocked=False else: @@ -86,7 +92,8 @@ class VideoComfy: self.scheduler.key=None self.switch_phase='Freie GPUs prüfen';self._start() with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all() - self.state='ready' if mode=='video' else 'idle' + self.state='ready' if mode in ('video','music') else 'idle' + if mode=='music':self.switch_phase='Musik bereit · Gewichte laden bei Anfrage' except Exception as exc: self._stop();self.error=str(exc);self.state='failed' with self.scheduler.cv:self.scheduler.gpu_mode='video' if self.foreign_blocked else 'llm';self.scheduler.cv.notify_all()