From b2256647e9c67a8d41fce1b6e70f0796a4d24a30 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:47:47 +0200 Subject: [PATCH] Add configurable TTS and STT residency policies --- ENDPOINT.md | 6 +++++ audio_policy.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ deploy/install.py | 2 +- server.py | 7 +++++ stt-ui.js | 9 +++++-- stt.py | 62 +++++++++++++++++++++++++++++-------------- test_audio_policy.py | 36 +++++++++++++++++++++++++ tts-ui.js | 7 ++++- tts_test.py | 57 ++++++++++++++++++++++++++++----------- 9 files changed, 209 insertions(+), 40 deletions(-) create mode 100644 audio_policy.py create mode 100644 test_audio_policy.py diff --git a/ENDPOINT.md b/ENDPOINT.md index acdb222..6c8db64 100644 --- a/ENDPOINT.md +++ b/ENDPOINT.md @@ -176,6 +176,12 @@ Profilfreigaben: mehrere Chatprofile, höchstens ein Bildprofil. Die Auswahl ein `POST /v1/audio/transcriptions` benötigt Bearer-Token und ein explizit freigegebenes STT-Profil. Multipart-Felder: `model` (API-Profilname), `file` (PCM16-WAV, mono, 16 kHz, maximal 120 Sekunden / 8 MiB), optional `language` (`de`, `en`, `auto`, Standard `de`), `response_format` (`json`). Antwort: `{"text":"…"}`. Andere Formate, Chunked-Uploads, Zeitstempel und Streaming werden abgelehnt. Ein STT-Auftrag zur Zeit; der CPU-Worker bleibt danach für weitere Aufnahmen geladen. Er wird bei Endpunkt-Stopp, einem neuen Build oder Fehler entladen. Keine Nutzung des alten Routers. +### Speicherverhalten von TTS und STT + +Unter **TTS/STT → Einrichten** lässt sich für beide Worker unabhängig **Automatisch** (Standard: beim ersten Auftrag laden und behalten), **Immer bereit** (ausgewähltes ausführbares Profil bei laufendem Endpunkt vorladen und nach Verdrängung erneut laden) oder **Nach jeder Anfrage entladen** wählen. TTS lädt in „Immer bereit“ nur im LLM-GPU-Modus und nur bei ausreichendem freiem RTX-3060-Speicher; der exklusive Videomodus behält Vorrang. STT lädt auf der CPU. Bei fehlender Laufzeit oder unzureichenden Ressourcen bleibt die Auswahl gespeichert und die Fehlermeldung erscheint in der Verwaltungs-API. Das Vorladen wird später erneut versucht und unterbricht keine fremden Dienste. Die Auswahl ändert keine Modellprofile und benötigt keine zusätzliche Runtime. + +Interne Verwaltungs-API (angemeldete Oberfläche): `GET /api/v1/audio-policy` liefert `settings` und `errors`; `POST /api/v1/audio-policy` erwartet `{ "kind": "tts|stt", "mode": "auto|warm|per_request", "profile_id": "Profil-ID oder null" }`. Für `warm` muss ein ausführbares Profil gewählt werden. Die Oberfläche verwendet diese API. + ### Modelllisten für Clients `GET /v1/models` listet ausschließlich Chatprofile, damit Chatclients keine Bild-/Audio-Profile anbieten. Deck-Erweiterungen: `GET /v1/images/models` für Bildprofile, `GET /v1/audio/speech/models` für TTS und `GET /v1/audio/transcriptions/models` für STT. Alle Listen erfordern denselben Bearer-Token und enthalten nur freigegebene, ausführbare Profile. Die Inferenzrouten bleiben unverändert. Die Verwaltungs-API liefert weiterhin die Gesamtübersicht. diff --git a/audio_policy.py b/audio_policy.py new file mode 100644 index 0000000..a5549e9 --- /dev/null +++ b/audio_policy.py @@ -0,0 +1,63 @@ +"""Persisted residency preferences for Deck-owned speech workers.""" +import json +import threading +import time +from pathlib import Path + +MODES=('auto','warm','per_request') + +class AudioPolicy: + def __init__(self,path,profiles,tts,stt,scheduler,endpoint): + self.path=Path(path);self.profiles=profiles;self.tts=tts;self.stt=stt;self.scheduler=scheduler;self.endpoint=endpoint + self.lock=threading.RLock();self.settings={kind:{'mode':'auto','profile_id':None} for kind in ('tts','stt')};self.errors={};self.retry_after={} + if self.path.exists(): + try: + stored=json.loads(self.path.read_text()) + for kind in self.settings: + value=stored.get(kind,{}) + if value.get('mode') in MODES and (value.get('profile_id') is None or isinstance(value.get('profile_id'),str)): + self.settings[kind]=dict(mode=value['mode'],profile_id=value.get('profile_id')) + except (OSError,ValueError,AttributeError,TypeError):pass + self.tts.policy=self.settings['tts']['mode'];self.stt.policy=self.settings['stt']['mode'] + self.thread=threading.Thread(target=self._loop,daemon=True);self.thread.start() + def status(self): + with self.lock:return dict(settings={k:dict(v) for k,v in self.settings.items()},errors=dict(self.errors)) + def configure(self,data): + if not isinstance(data,dict) or set(data)!={'kind','mode','profile_id'}:raise ValueError('Art, Modus und Profil-ID erforderlich.') + kind=data['kind'];mode=data['mode'];ident=data['profile_id'] + if kind not in self.settings or mode not in MODES:raise ValueError('Ungültiger Audio-Modus.') + if ident is not None and (not isinstance(ident,str) or len(ident)>128):raise ValueError('Ungültige Profil-ID.') + if mode=='warm': + profile_kind='audio' if kind=='tts' else 'stt' + if not ident or not any(p['id']==ident and p['kind']==profile_kind and p['runnable'] for p in self.profiles.status()['profiles']):raise ValueError('Für „Immer bereit“ ein ausführbares Profil auswählen.') + with self.lock: + updated={k:dict(v) for k,v in self.settings.items()};updated[kind]=dict(mode=mode,profile_id=ident) + self.path.parent.mkdir(parents=True,exist_ok=True);temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(updated));temp.replace(self.path) + self.settings=updated;self.errors.pop(kind,None);self.retry_after.pop(kind,None) + worker=self.tts if kind=='tts' else self.stt;worker.policy=mode + if mode=='per_request': + with worker.lock:active=bool(worker.job and worker.job.get('state')=='running') + if not active:worker.unload_idle() + return self.status() + def _loop(self): + while True: + time.sleep(8) + with self.lock:settings={k:dict(v) for k,v in self.settings.items()} + if self.endpoint.state!='running':continue + for kind,worker in (('stt',self.stt),('tts',self.tts)): + item=settings[kind] + if item['mode']!='warm':continue + with self.lock: + if time.monotonic(){ 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:{'X-Athena-Deck':'1',...(data instanceof FormData?{}:{'Content-Type':'application/json'})},body:data instanceof FormData?data:JSON.stringify(data)});const result=await response.json();if(!response.ok)throw Error(result.error||'STT-Anfrage fehlgeschlagen');return result;} - function html(setup=false){return `

${setup?'Spracherkennung einrichten':'Audio in Text umwandeln'}

${setup?'

Qwen3-ASR 0.6B Q8_0 nutzt die vorhandene llama.cpp-Laufzeit auf der CPU. Installiert werden die geprüfte Modellvariante von ggml-org (rund 805 MB) und der Audio-Projektor (214 MB). Andere GGUF-Konvertierungen können inkompatibel sein. Bestehende Dienste werden nicht verwendet oder verändert.

llama.cpp installieren / Build auswählen →

Der Download erscheint unter STT → Downloads. Nach Abschluss ein neues Profil aus der Bibliothek anlegen oder ein bestehendes Profil unten ausdrücklich umstellen. Alte Dateien bleiben erhalten.

Vorhandenes Profil umstellen

Ersetzt die Modellzuordnung durch die geprüfte ggml-org-Variante. Profilname bleibt erhalten.

':'

Transkript

Die Audiodatei wird im Browser in Mono-WAV mit 16 kHz umgewandelt. Unterstützte Eingabeformate hängen vom Browser ab. Audio und Transkript werden nicht dauerhaft gespeichert; das letzte Ergebnis bleibt bis zum nächsten Auftrag im Arbeitsspeicher.

'}
`;} + function html(setup=false){return `

${setup?'Spracherkennung einrichten':'Audio in Text umwandeln'}

${setup?'

Qwen3-ASR 0.6B Q8_0 nutzt die vorhandene llama.cpp-Laufzeit auf der CPU. Installiert werden die geprüfte Modellvariante von ggml-org (rund 805 MB) und der Audio-Projektor (214 MB). Andere GGUF-Konvertierungen können inkompatibel sein. Bestehende Dienste werden nicht verwendet oder verändert.

llama.cpp installieren / Build auswählen →

Der Download erscheint unter STT → Downloads. Nach Abschluss ein neues Profil aus der Bibliothek anlegen oder ein bestehendes Profil unten ausdrücklich umstellen. Alte Dateien bleiben erhalten.

Vorhandenes Profil umstellen

Ersetzt die Modellzuordnung durch die geprüfte ggml-org-Variante. Profilname bleibt erhalten.

Speicherverhalten

Automatisch hält das Modell nach der ersten Anfrage geladen. Immer bereit lädt das gewählte Profil im LLM-Betrieb vor, soweit Ressourcen frei sind. Nach jeder Anfrage entladen gibt Speicher sofort frei.

':'

Transkript

Die Audiodatei wird im Browser in Mono-WAV mit 16 kHz umgewandelt. Unterstützte Eingabeformate hängen vom Browser ab. Audio und Transkript werden nicht dauerhaft gespeichert; das letzte Ergebnis bleibt bis zum nächsten Auftrag im Arbeitsspeicher.

'}
`;} async function wav(file){if(file.size>20*1024**2)throw Error('Datei ist größer als 20 MiB.');const context=new AudioContext();let decoded;try{decoded=await context.decodeAudioData(await file.arrayBuffer());}finally{await context.close();}if(!decoded.duration||decoded.duration>120)throw Error('Maximal 120 Sekunden Audio erlaubt.');const offline=new OfflineAudioContext(1,Math.ceil(decoded.duration*16000),16000),source=offline.createBufferSource();source.buffer=decoded;source.connect(offline.destination);source.start();const samples=(await offline.startRendering()).getChannelData(0),buffer=new ArrayBuffer(44+samples.length*2),v=new DataView(buffer);const str=(at,s)=>{for(let i=0;iv.setInt16(44+i*2,Math.max(-1,Math.min(1,s))*(s<0?32768:32767),true));return new Blob([buffer],{type:'audio/wav'});} function bind(setup=false){const root=document.querySelector('#stt-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let busy=false,profiles=[]; async function refresh(){try{const s=await api('stt');if(!root.isConnected)return;el('stt-status').textContent=setup?`llama.cpp: ${s.installed?'vorhanden':'fehlt'} · Modell: ${s.model?'vorhanden':'fehlt'} · Audio-Projektor: ${s.projector?'vorhanden':'fehlt'}`:(s.job?.phase||'Profil und Audiodatei auswählen.')+` · CPU-Modell ${s.loaded?'geladen':'nicht geladen'}`;if(setup){el('stt-setup').disabled=busy||(s.projector&&s.model);el('stt-assign').disabled=busy||!s.model||!profiles.length;}else{el('stt-start').disabled=busy||s.job?.state==='running'||!profiles.length;el('stt-cancel').disabled=s.job?.state!=='running';el('stt-result').textContent=s.job?.text||'';}}catch(err){if(root.isConnected)el('stt-error').textContent=err.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}} if(setup){api('profiles').then(p=>{if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='stt');el('stt-profile').innerHTML=profiles.map(p=>``).join('');}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});el('stt-assign').onclick=async()=>{const p=profiles.find(p=>p.id===el('stt-profile').value);if(!p)return;busy=true;try{await api('stt/assign',{id:p.id,revision:p.revision});el('stt-error').textContent='Profil umgestellt. Jetzt STT → Testen öffnen.';p.revision++;}catch(err){el('stt-error').textContent=err.message;}finally{busy=false;}};el('stt-setup').onclick=async()=>{busy=true;try{await api('stt/setup',{});el('stt-error').textContent='Download ist eingeplant. Fortschritt unter Downloads.';}catch(err){el('stt-error').textContent=err.message;}finally{busy=false;}};} - else{api('profiles').then(p=>{if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='stt'&&p.runnable);el('stt-profile').innerHTML=profiles.map(p=>``).join('')||'';}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});el('stt-form').onsubmit=async ev=>{ev.preventDefault();busy=true;el('stt-start').disabled=true;el('stt-error').textContent='';try{const body=new FormData();body.set('file',await wav(el('stt-file').files[0]),'audio.wav');body.set('profile_id',el('stt-profile').value);body.set('language',el('stt-language').value);if(root.isConnected)await api('stt/start',body);}catch(err){if(root.isConnected)el('stt-error').textContent=err.message;}finally{busy=false;}};el('stt-cancel').onclick=()=>api('stt/cancel',{}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});}refresh(); + else{api('profiles').then(p=>{if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='stt'&&p.runnable);el('stt-profile').innerHTML=profiles.map(p=>``).join('')||'';}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});el('stt-form').onsubmit=async ev=>{ev.preventDefault();busy=true;el('stt-start').disabled=true;el('stt-error').textContent='';try{const body=new FormData();body.set('file',await wav(el('stt-file').files[0]),'audio.wav');body.set('profile_id',el('stt-profile').value);body.set('language',el('stt-language').value);if(root.isConnected)await api('stt/start',body);}catch(err){if(root.isConnected)el('stt-error').textContent=err.message;}finally{busy=false;}};el('stt-cancel').onclick=()=>api('stt/cancel',{}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});} if(setup){ + const policy=el('stt-policy'),warm=el('stt-warm-profile'),save=el('stt-policy-save'),status=el('stt-policy-state'); + Promise.all([api('audio-policy'),api('profiles')]).then(([current,rows])=>{if(!root.isConnected)return;warm.innerHTML=rows.profiles.filter(p=>p.kind==='stt'&&p.runnable).map(p=>``).join('');const selected=current.settings.stt;policy.value=selected.mode;if(selected.profile_id)warm.value=selected.profile_id;status.textContent=current.errors.stt||'';}).catch(err=>{if(root.isConnected)status.textContent=err.message;}); + save.onclick=async()=>{save.disabled=true;try{const updated=await api('audio-policy',{kind:'stt',mode:policy.value,profile_id:warm.value||null});status.textContent=updated.errors.stt||'Gespeichert.';}catch(err){status.textContent=err.message;}finally{save.disabled=false;}}; + } +refresh(); } return {html,bind}; })(); diff --git a/stt.py b/stt.py index 051032f..e72315b 100644 --- a/stt.py +++ b/stt.py @@ -50,7 +50,7 @@ def read_upload(handler): class STT: def __init__(self,profiles,catalog,runtime): - self.profiles=profiles;self.catalog=catalog;self.runtime=runtime;self.lock=threading.RLock();self.job=None;self.process=None;self.port=None;self.key=None;self.binary_path=None;self.cancel=threading.Event() + self.profiles=profiles;self.catalog=catalog;self.runtime=runtime;self.lock=threading.RLock();self.job=None;self.process=None;self.port=None;self.key=None;self.binary_path=None;self.cancel=threading.Event();self.policy='auto';self.warming=False def build(self): state=self.runtime.status();ident=state.get('active') if not isinstance(ident,str) or len(ident)!=32 or any(c not in '0123456789abcdef' for c in ident):raise ValueError('Zuerst unter Laufzeiten → llama.cpp einen Build erstellen und aktivieren.') @@ -75,7 +75,7 @@ class STT: except ValueError:projector=False try:self.model_entry();model=True except ValueError:model=False - with self.lock:return dict(model=model,job=dict(self.job) if self.job else None,installed=installed,projector=projector,repo=REPO,revision=REVISION,loaded=bool(self.process and self.process.poll() is None)) + with self.lock:return dict(model=model,job=dict(self.job) if self.job else None,installed=installed,projector=projector,repo=REPO,revision=REVISION,loaded=bool(self.process and self.process.poll() is None),policy=self.policy,warming=self.warming) def model_entry(self): for x in self.catalog.status()['entries']: if supported(x):return self.catalog.entry(x['id']) @@ -99,6 +99,7 @@ class STT: validate_wav(audio) if language not in ('de','en','auto'):raise ValueError('Sprache muss de, en oder auto sein.') with self.lock: + if self.warming:raise ValueError('STT wird gerade vorgeladen. Gleich erneut versuchen.') if self.job and self.job['state']=='running':raise ValueError('Ein STT-Auftrag läuft bereits.') p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='stt'),None) if not p or not p['runnable']:raise ValueError('STT-Profil nicht ausführbar. Zuerst Einrichten öffnen.') @@ -110,6 +111,29 @@ class STT: threading.Thread(target=self._run,args=(binary,model,projector,audio,language),daemon=True).start();return dict(self.job) def _run(self,binary,model,projector,audio,language): process=None;result=dict(state='failed',phase='Spracherkennung fehlgeschlagen. Laufzeit und Speicher prüfen.') + try: + port,key=self._ensure(binary,model,projector) + base=f'http://127.0.0.1:{port}';headers={'Authorization':'Bearer '+key};deadline=time.monotonic()+90 + with self.lock:self.job['phase']='Audio wird auf der CPU transkribiert' + boundary='deck-'+uuid.uuid4().hex + body=f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n'.encode()+audio+b'\r\n' + for name,value in [('model','deck-stt')]+([] if language=='auto' else [('language',language)]):body+=f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode() + body+=f'--{boundary}--\r\n'.encode();headers['Content-Type']='multipart/form-data; boundary='+boundary + with urllib.request.urlopen(urllib.request.Request(base+'/v1/audio/transcriptions',data=body,headers=headers),timeout=180) as response: + payload=json.loads(response.read(1024*1024)) + if not isinstance(payload.get('text'),str):raise ValueError('Die Laufzeit hat kein Transkript geliefert.') + text=payload['text'].split('')[-1].replace('<|endoftext|>','').strip() + result=dict(state='complete',phase='Transkription fertig · '+('Modell entladen' if self.policy=='per_request' else 'Modell bleibt geladen'),text=text) + except InterruptedError:result=dict(state='cancelled',phase='Transkription abgebrochen') + except ValueError as exc:result=dict(state='failed',phase=str(exc)) + except Exception:pass + finally: + if result['state']!='complete' or self.policy=='per_request':self.unload_idle() + with self.lock: + if self.cancel.is_set():result=dict(state='cancelled',phase='Transkription abgebrochen') + self.job.update(result) + def _ensure(self,binary,model,projector): + process=None try: with self.lock: if self.cancel.is_set():raise InterruptedError() @@ -134,25 +158,23 @@ class STT: except OSError:continue else: with self.lock:port=self.port;key=self.key - base=f'http://127.0.0.1:{port}';headers={'Authorization':'Bearer '+key};deadline=time.monotonic()+90 - with self.lock:self.job['phase']='Audio wird auf der CPU transkribiert' - boundary='deck-'+uuid.uuid4().hex - body=f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n'.encode()+audio+b'\r\n' - for name,value in [('model','deck-stt')]+([] if language=='auto' else [('language',language)]):body+=f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode() - body+=f'--{boundary}--\r\n'.encode();headers['Content-Type']='multipart/form-data; boundary='+boundary - with urllib.request.urlopen(urllib.request.Request(base+'/v1/audio/transcriptions',data=body,headers=headers),timeout=180) as response: - payload=json.loads(response.read(1024*1024)) - if not isinstance(payload.get('text'),str):raise ValueError('Die Laufzeit hat kein Transkript geliefert.') - text=payload['text'].split('')[-1].replace('<|endoftext|>','').strip() - result=dict(state='complete',phase='Transkription fertig · Modell bleibt geladen',text=text) - except InterruptedError:result=dict(state='cancelled',phase='Transkription abgebrochen') - except ValueError as exc:result=dict(state='failed',phase=str(exc)) - except Exception:pass + return port,key + except Exception: + self.unload_idle();raise + def warm(self,profile_id): + with self.lock: + if self.job and self.job.get('state')=='running':return False + if self.process and self.process.poll() is None:return True + if self.warming:return False + p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='stt' and p['runnable']),None) + if not p:raise ValueError('Das ausgewählte STT-Profil ist nicht ausführbar.') + self.warming=True;self.cancel.clear() + try: + headroom=cgroup_headroom() + if headroom is not None and headroom<4*1024**3:raise ValueError('Mindestens 4 GiB freier Deck-RAM werden benötigt.') + self._ensure(self.build(),self.catalog.root/p['model_id']/'model.gguf',self.catalog.root/self.projector()['id']/'model.gguf');return True finally: - if result['state']!='complete':self.unload_idle() - with self.lock: - if self.cancel.is_set():result=dict(state='cancelled',phase='Transkription abgebrochen') - self.job.update(result) + with self.lock:self.warming=False def unload_idle(self): with self.lock: process=self.process;self.process=None;self.port=None;self.key=None;self.binary_path=None diff --git a/test_audio_policy.py b/test_audio_policy.py new file mode 100644 index 0000000..941d708 --- /dev/null +++ b/test_audio_policy.py @@ -0,0 +1,36 @@ +import tempfile +import unittest +from pathlib import Path +from audio_policy import AudioPolicy + +class Worker: + def __init__(self): + import threading + self.lock=threading.RLock();self.policy='auto';self.job=None;self.process=None;self.warming=False;self.unloaded=0 + def unload_idle(self):self.unloaded+=1 + +class Profiles: + def status(self):return {'profiles':[{'id':'tts-1','kind':'audio','runnable':True},{'id':'stt-1','kind':'stt','runnable':True}]} + +class Endpoint: + state='stopped' + +class Scheduler: + def __init__(self): + import threading + self.cv=threading.Condition();self.gpu_mode='llm';self.active=0;self.transition=False + +class PolicyTests(unittest.TestCase): + def test_persist_validate_and_unload(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'audio-policy.json';tts=Worker();stt=Worker() + manager=AudioPolicy(path,Profiles(),tts,stt,Scheduler(),Endpoint()) + with self.assertRaises(ValueError):manager.configure({'kind':'tts','mode':'warm','profile_id':'missing'}) + self.assertFalse(path.exists()) + manager.configure({'kind':'tts','mode':'warm','profile_id':'tts-1'}) + self.assertEqual(tts.policy,'warm') + manager.configure({'kind':'stt','mode':'per_request','profile_id':None}) + self.assertEqual(stt.unloaded,1) + restored=AudioPolicy(path,Profiles(),Worker(),Worker(),Scheduler(),Endpoint()) + self.assertEqual(restored.status()['settings']['tts']['mode'],'warm') + self.assertEqual(restored.status()['settings']['stt']['mode'],'per_request') diff --git a/tts-ui.js b/tts-ui.js index 804fa50..7e34b8d 100644 --- a/tts-ui.js +++ b/tts-ui.js @@ -1,7 +1,7 @@ window.TTSUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); 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||'TTS-Anfrage fehlgeschlagen');return x;} - function html(setup=false){return `

${setup?'Qwen3-TTS einrichten':'Sprache ausprobieren'}

${setup?'

Die Einrichtung installiert eine eigene CUDA-Laufzeit und das offizielle Qwen3-TTS 1.7B CustomVoice mit eingebauten Stimmen. Dafür werden mehrere GB heruntergeladen. MLX-Dateien sind für Apple-Geräte; vorhandene Dateien werden nicht gelöscht.

Vorhandenes TTS-Profil verbinden

Ersetzt im ausgewählten Profil die Modellzuordnung durch die eingerichtete CUDA-Variante. Der Profilname bleibt erhalten.

':'

Ausführung auf der RTX 3060. Andere Deck-Modellaufträge werden koordiniert; fremde Dienste werden nicht gestoppt. Text wird nicht dauerhaft gespeichert. Ergebnisdateien bleiben auf Athena.

'}
`;} + function html(setup=false){return `

${setup?'Qwen3-TTS einrichten':'Sprache ausprobieren'}

${setup?'

Die Einrichtung installiert eine eigene CUDA-Laufzeit und das offizielle Qwen3-TTS 1.7B CustomVoice mit eingebauten Stimmen. Dafür werden mehrere GB heruntergeladen. MLX-Dateien sind für Apple-Geräte; vorhandene Dateien werden nicht gelöscht.

Vorhandenes TTS-Profil verbinden

Ersetzt im ausgewählten Profil die Modellzuordnung durch die eingerichtete CUDA-Variante. Der Profilname bleibt erhalten.

Speicherverhalten

Automatisch hält das Modell nach der ersten Anfrage geladen. Immer bereit lädt das gewählte Profil im LLM-Betrieb vor, soweit Ressourcen frei sind. Nach jeder Anfrage entladen gibt Speicher sofort frei.

':'

Ausführung auf der RTX 3060. Andere Deck-Modellaufträge werden koordiniert; fremde Dienste werden nicht gestoppt. Text wird nicht dauerhaft gespeichert. Ergebnisdateien bleiben auf Athena.

'}
`;} function bind(setup=false){const root=document.querySelector('#tts-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let state,profiles=[],busy=false,lastResult='';const error=x=>{if(root.isConnected)el('tts-error').textContent=x;}; async function refresh(){try{state=await api('tts');if(!root.isConnected)return;const rt=state.runtime,j=state.job;el('tts-status').textContent=setup?(rt.job?.phase||(rt.installed?'TTS ist eingerichtet.':'Noch nicht eingerichtet.')):(j?.phase||'Profil wählen und Text eingeben.')+` · RTX 3060 ${state.loaded?'geladen':'nicht geladen'}`; if(setup){el('tts-install').disabled=busy||rt.installed||rt.job?.state==='running';el('tts-install-cancel').disabled=rt.job?.state!=='running';el('tts-assign').disabled=busy||!rt.installed||!profiles.length;} @@ -11,6 +11,11 @@ window.TTSUI=(()=>{ async function action(path,data={}){busy=true;error('');try{await api('tts/'+path,data);if(path==='assign'){await load();error('Profil verbunden. Jetzt den Reiter Testen öffnen.');}}catch(err){error(err.message);}finally{busy=false;}} if(setup){el('tts-install').onclick=()=>action('install');el('tts-install-cancel').onclick=()=>action('install-cancel');el('tts-assign').onclick=()=>{const p=profiles.find(p=>p.id===el('tts-profile').value);if(p)action('assign',{id:p.id,revision:p.revision});};} else{api('tts').then(s=>{if(!root.isConnected)return;el('tts-speaker').innerHTML=s.runtime.speakers.map(v=>``).join('');el('tts-language').innerHTML=s.runtime.languages.map(v=>``).join('');}).catch(x=>error(x.message));el('tts-form').onsubmit=ev=>{ev.preventDefault();action('start',{profile_id:el('tts-profile').value,text:el('tts-text').value,speaker:el('tts-speaker').value,language:el('tts-language').value});};el('tts-cancel').onclick=()=>action('cancel');} + if(setup){ + const policy=el('tts-policy'),warm=el('tts-warm-profile'),save=el('tts-policy-save'),status=el('tts-policy-state'); + Promise.all([api('audio-policy'),api('profiles')]).then(([current,rows])=>{if(!root.isConnected)return;warm.innerHTML=rows.profiles.filter(p=>p.kind==='audio'&&p.runnable).map(p=>``).join('');const selected=current.settings.tts;policy.value=selected.mode;if(selected.profile_id)warm.value=selected.profile_id;status.textContent=current.errors.tts||'';}).catch(err=>{if(root.isConnected)status.textContent=err.message;}); + save.onclick=async()=>{save.disabled=true;try{const updated=await api('audio-policy',{kind:'tts',mode:policy.value,profile_id:warm.value||null});status.textContent=updated.errors.tts||'Gespeichert.';}catch(err){status.textContent=err.message;}finally{save.disabled=false;}}; + } load().catch(x=>error(x.message));refresh(); } return {html,bind}; diff --git a/tts_test.py b/tts_test.py index 3e9de5e..08ce9df 100644 --- a/tts_test.py +++ b/tts_test.py @@ -6,9 +6,9 @@ from image_test import probe,cgroup_headroom WORKER=Path(__file__).with_name('tts_worker.py') class TTSTests: def __init__(self,root,profiles,runtime): - self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.lock=threading.RLock();self.job=None;self.process=None;self.gpu_uuid=None;self.cancel=threading.Event();self.acquire=lambda wait=False:lambda:None + self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.lock=threading.RLock();self.job=None;self.process=None;self.gpu_uuid=None;self.cancel=threading.Event();self.acquire=lambda wait=False:lambda:None;self.policy='auto';self.warming=False def status(self): - with self.lock:return dict(job=dict(self.job) if self.job else None,runtime=self.runtime.status(),loaded=bool(self.process and self.process.poll() is None),gpu_uuid=self.gpu_uuid) + with self.lock:return dict(job=dict(self.job) if self.job else None,runtime=self.runtime.status(),loaded=bool(self.process and self.process.poll() is None),gpu_uuid=self.gpu_uuid,warming=self.warming,policy=self.policy) def blockers(self,p): if p['model']['repo']!=REPO or p['model']['file']!='model.safetensors' or p['model'].get('revision')!=REVISION:return ['Für diese TTS-Variante fehlt die Anbindung in Deck. Unter TTS → Einrichten kannst du ausdrücklich auf die unterstützte Variante mit eingebauten Stimmen wechseln. Die ursprüngliche Datei bleibt erhalten.'] return [] if self.runtime.status()['installed'] else ['TTS-Laufzeit unter TTS → Einrichten installieren.'] @@ -25,6 +25,7 @@ class TTSTests: release=self.acquire(wait) try: with self.lock: + if self.warming:raise ValueError('TTS wird gerade vorgeladen. Gleich erneut versuchen.') if self.job and self.job['state']=='running':raise ValueError('TTS-Auftrag läuft bereits.') p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='audio'),None) if not p or not p['runnable']:raise ValueError('TTS-Profil noch nicht eingerichtet.') @@ -40,18 +41,7 @@ class TTSTests: def _run(self,ident,text,speaker,language,speed,gpu,release): directory=self.root/ident;process=None;state='failed';phase='TTS-Ausführung fehlgeschlagen' try: - directory.mkdir(mode=0o700);python,model=self.runtime.paths() - with self.lock: - if self.cancel.is_set():raise InterruptedError() - process=self.process if self.process and self.process.poll() is None else None - new_process=process is None - if process is None: - env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','LLAMA_'))};env.update(CUDA_VISIBLE_DEVICES=gpu['uuid'],HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='2',HOME=str(self.root)) - process=subprocess.Popen([str(python),str(WORKER),str(model)],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,start_new_session=True,env=env,text=True,bufsize=1);self.process=process;self.gpu_uuid=gpu['uuid'] - if new_process: - if not select.select([process.stdout],[],[],180)[0]:raise ValueError('Zeitlimit beim Laden des TTS-Modells.') - ready=json.loads(process.stdout.readline()) - if not ready.get('ready'):raise ValueError(ready.get('error','TTS-Modell konnte nicht geladen werden.')) + directory.mkdir(mode=0o700);process=self._ensure(gpu) with self.lock:self.job['phase']='Sprache wird auf der RTX 3060 erzeugt' process.stdin.write(json.dumps(dict(output=str(directory),text=text,speaker=speaker,language=language,speed=speed))+'\n');process.stdin.flush() end=time.monotonic()+600 @@ -66,14 +56,49 @@ class TTSTests: if not result['ok']:raise ValueError(result['error']) wav=directory/'result.wav' if not wav.exists() or wav.stat().st_size>32*1024**2:raise ValueError('Ungültige Audioausgabe.') - state='complete';phase='Sprache fertig · Modell bleibt geladen' + state='complete';phase='Sprache fertig · '+('Modell entladen' if self.policy=='per_request' else 'Modell bleibt geladen') except InterruptedError:state='cancelled';phase='Sprachgenerierung abgebrochen' except Exception as exc:phase=str(exc) if isinstance(exc,ValueError) else 'Sprachgenerierung fehlgeschlagen. Laufzeit und Ressourcen prüfen.' finally: - if state!='complete':self.unload_idle() + if state!='complete' or self.policy=='per_request':self.unload_idle() if state=='complete':(directory/'complete').touch() with self.lock:self.job.update(state=state,phase=phase,finished_at=time.time()) release() + def _ensure(self,gpu): + python,model=self.runtime.paths() + with self.lock: + if self.cancel.is_set():raise InterruptedError() + process=self.process if self.process and self.process.poll() is None else None + if process:return process + self.root.mkdir(parents=True,exist_ok=True,mode=0o700) + env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','LLAMA_'))};env.update(CUDA_VISIBLE_DEVICES=gpu['uuid'],HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='2',HOME=str(self.root)) + process=subprocess.Popen([str(python),str(WORKER),str(model)],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,start_new_session=True,env=env,text=True,bufsize=1);self.process=process;self.gpu_uuid=gpu['uuid'] + if not select.select([process.stdout],[],[],180)[0]:raise ValueError('Zeitlimit beim Laden des TTS-Modells.') + if self.cancel.is_set():raise InterruptedError() + ready=json.loads(process.stdout.readline()) + if not ready.get('ready'):raise ValueError(ready.get('error','TTS-Modell konnte nicht geladen werden.')) + return process + def warm(self,profile_id): + release=self.acquire(True) + try: + with self.lock: + if self.job and self.job.get('state')=='running':return False + if self.process and self.process.poll() is None:return True + if self.warming:return False + p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='audio' and p['runnable']),None) + if not p:raise ValueError('Das ausgewählte TTS-Profil ist nicht ausführbar.') + self.warming=True;self.cancel.clear() + try: + gpu=next((g for g in probe() if 'RTX 3060' in g['name'] and not g['processes'] and g['free_mib']>7000),None) + if not gpu:raise ValueError('Für dauerhaftes TTS sind mindestens 7 GiB freie RTX-3060-VRAM nötig.') + headroom=cgroup_headroom() + if headroom is not None and headroom<8*1024**3:raise ValueError('Zu wenig freier Deck-RAM für TTS.') + self._ensure(gpu);return True + except Exception: + self.unload_idle();raise + finally: + with self.lock:self.warming=False + finally:release() def unload_idle(self): with self.lock: process=self.process;self.process=None;self.gpu_uuid=None