Add configurable TTS and STT residency policies

This commit is contained in:
Mikei386
2026-09-29 20:47:47 +02:00
parent 90a97567f3
commit b2256647e9
9 changed files with 209 additions and 40 deletions
+6
View File
@@ -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. `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 ### 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. `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.
+63
View File
@@ -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()<self.retry_after.get(kind,0):continue
if kind=='tts':
with self.scheduler.cv:
if self.scheduler.gpu_mode!='llm' or self.scheduler.active or self.scheduler.transition:continue
with worker.lock:
if worker.warming or worker.process and worker.process.poll() is None or worker.job and worker.job.get('state')=='running':continue
try:
worker.warm(item['profile_id'])
with self.lock:self.errors.pop(kind,None)
except Exception as exc:
with self.lock:
self.errors[kind]=str(exc) if isinstance(exc,ValueError) else 'Vorladen fehlgeschlagen. Ressourcen und Laufzeit prüfen.'
self.retry_after[kind]=time.monotonic()+60
+1 -1
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['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','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 = ['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','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): def run(*args, check=True, interactive=False):
+7
View File
@@ -11,6 +11,7 @@ from auth import CredentialStore, verify_password
from catalog import Catalog from catalog import Catalog
from tts_runtime import TTSRuntime from tts_runtime import TTSRuntime
from stt import STT,read_upload from stt import STT,read_upload
from audio_policy import AudioPolicy
from tts_test import TTSTests from tts_test import TTSTests
from profiles import Profiles from profiles import Profiles
from capacity import assess, overview from capacity import assess, overview
@@ -110,6 +111,7 @@ class Server(ThreadingHTTPServer):
if self.endpoint.config['autostart']: if self.endpoint.config['autostart']:
try:self.endpoint.start() try:self.endpoint.start()
except ValueError as exc:self.endpoint.error=str(exc) except ValueError as exc:self.endpoint.error=str(exc)
self.audio_policy=AudioPolicy(self.catalog.root.parent/'audio-policy.json',self.profiles,self.tts_tests,self.stt,self.scheduler,self.endpoint)
self.sessions = {} self.sessions = {}
self.login_attempts = [] self.login_attempts = []
self.auth_lock = threading.Lock() self.auth_lock = threading.Lock()
@@ -279,6 +281,7 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/docker':return self.respond(self.server.docker.status()) if self.path == '/api/v1/docker':return self.respond(self.server.docker.status())
if self.path == '/api/v1/stt':return self.respond(self.server.stt.status()) if self.path == '/api/v1/stt':return self.respond(self.server.stt.status())
if self.path == '/api/v1/tts':return self.respond(self.server.tts_tests.status()) if self.path == '/api/v1/tts':return self.respond(self.server.tts_tests.status())
if self.path == '/api/v1/audio-policy':return self.respond(self.server.audio_policy.status())
if urlsplit(self.path).path == '/api/v1/tts/audio': if urlsplit(self.path).path == '/api/v1/tts/audio':
try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav') try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
except (OSError,ValueError):return self.respond({'error':'Audio nicht verfügbar.'},404) except (OSError,ValueError):return self.respond({'error':'Audio nicht verfügbar.'},404)
@@ -351,6 +354,10 @@ class Handler(BaseHTTPRequestHandler):
return self.login() return self.login()
if not self.authenticated(): if not self.authenticated():
return self.respond({'error':'Anmeldung oder gültiger API-Token erforderlich.'},401) return self.respond({'error':'Anmeldung oder gültiger API-Token erforderlich.'},401)
if self.path == '/api/v1/audio-policy':
try:return self.respond(self.server.audio_policy.configure(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Audio-Einstellung konnte nicht gespeichert werden.'},503)
if self.path == '/api/v1/huggingface': if self.path == '/api/v1/huggingface':
try: try:
data=self.read_json() data=self.read_json()
+7 -2
View File
@@ -1,12 +1,17 @@
window.STTUI=(()=>{ window.STTUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;} 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 `<section class="card" id="stt-panel"><h2>${setup?'Spracherkennung einrichten':'Audio in Text umwandeln'}</h2><p id="stt-status" role="status"></p><p id="stt-error" role="alert"></p>${setup?'<p>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.</p><a class="link" href="#runtime">llama.cpp installieren / Build auswählen →</a><p><button id="stt-setup">Modell und Audio-Projektor einrichten</button></p><p>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.</p><h3>Vorhandenes Profil umstellen</h3><p>Ersetzt die Modellzuordnung durch die geprüfte ggml-org-Variante. Profilname bleibt erhalten.</p><select id="stt-profile"></select><button id="stt-assign">Kompatibles Modell diesem Profil zuordnen</button>':'<form id="stt-form"><label>Profil<select id="stt-profile" required></select></label><label>Audiodatei (maximal 120 Sekunden, 20 MiB)<input type="file" id="stt-file" accept="audio/*" required></label><label>Sprache<select id="stt-language"><option value="de">Deutsch</option><option value="en">Englisch</option><option value="auto">Automatisch</option></select></label><button id="stt-start">Transkribieren</button><button type="button" class="secondary" id="stt-cancel">Abbrechen</button></form><h3>Transkript</h3><pre id="stt-result" style="white-space:pre-wrap"></pre><p>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.</p>'}</section>`;} function html(setup=false){return `<section class="card" id="stt-panel"><h2>${setup?'Spracherkennung einrichten':'Audio in Text umwandeln'}</h2><p id="stt-status" role="status"></p><p id="stt-error" role="alert"></p>${setup?'<p>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.</p><a class="link" href="#runtime">llama.cpp installieren / Build auswählen →</a><p><button id="stt-setup">Modell und Audio-Projektor einrichten</button></p><p>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.</p><h3>Vorhandenes Profil umstellen</h3><p>Ersetzt die Modellzuordnung durch die geprüfte ggml-org-Variante. Profilname bleibt erhalten.</p><select id="stt-profile"></select><button id="stt-assign">Kompatibles Modell diesem Profil zuordnen</button><h3>Speicherverhalten</h3><p>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.</p><label>Verhalten<select id="stt-policy"><option value="auto">Automatisch</option><option value="warm">Immer bereit</option><option value="per_request">Nach jeder Anfrage entladen</option></select></label><label>Profil für „Immer bereit“<select id="stt-warm-profile"></select></label><button id="stt-policy-save">Speicherverhalten speichern</button><p id="stt-policy-state" role="status"></p>':'<form id="stt-form"><label>Profil<select id="stt-profile" required></select></label><label>Audiodatei (maximal 120 Sekunden, 20 MiB)<input type="file" id="stt-file" accept="audio/*" required></label><label>Sprache<select id="stt-language"><option value="de">Deutsch</option><option value="en">Englisch</option><option value="auto">Automatisch</option></select></label><button id="stt-start">Transkribieren</button><button type="button" class="secondary" id="stt-cancel">Abbrechen</button></form><h3>Transkript</h3><pre id="stt-result" style="white-space:pre-wrap"></pre><p>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.</p>'}</section>`;}
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;i<s.length;i++)v.setUint8(at+i,s.charCodeAt(i));};str(0,'RIFF');v.setUint32(4,36+samples.length*2,true);str(8,'WAVEfmt ');v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,1,true);v.setUint32(24,16000,true);v.setUint32(28,32000,true);v.setUint16(32,2,true);v.setUint16(34,16,true);str(36,'data');v.setUint32(40,samples.length*2,true);samples.forEach((s,i)=>v.setInt16(44+i*2,Math.max(-1,Math.min(1,s))*(s<0?32768:32767),true));return new Blob([buffer],{type:'audio/wav'});} 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;i<s.length;i++)v.setUint8(at+i,s.charCodeAt(i));};str(0,'RIFF');v.setUint32(4,36+samples.length*2,true);str(8,'WAVEfmt ');v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,1,true);v.setUint32(24,16000,true);v.setUint32(28,32000,true);v.setUint16(32,2,true);v.setUint16(34,16,true);str(36,'data');v.setUint32(40,samples.length*2,true);samples.forEach((s,i)=>v.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=[]; 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);}} 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=>`<option value="${e(p.id)}">${e(p.name)}</option>`).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;}};} 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=>`<option value="${e(p.id)}">${e(p.name)}</option>`).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=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst STT einrichten und ein Profil anlegen</option>';}).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=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst STT einrichten und ein Profil anlegen</option>';}).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=>`<option value="${e(p.id)}">${e(p.name)}</option>`).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}; return {html,bind};
})(); })();
+42 -20
View File
@@ -50,7 +50,7 @@ def read_upload(handler):
class STT: class STT:
def __init__(self,profiles,catalog,runtime): 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): def build(self):
state=self.runtime.status();ident=state.get('active') 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.') 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 except ValueError:projector=False
try:self.model_entry();model=True try:self.model_entry();model=True
except ValueError:model=False 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): def model_entry(self):
for x in self.catalog.status()['entries']: for x in self.catalog.status()['entries']:
if supported(x):return self.catalog.entry(x['id']) if supported(x):return self.catalog.entry(x['id'])
@@ -99,6 +99,7 @@ class STT:
validate_wav(audio) validate_wav(audio)
if language not in ('de','en','auto'):raise ValueError('Sprache muss de, en oder auto sein.') if language not in ('de','en','auto'):raise ValueError('Sprache muss de, en oder auto sein.')
with self.lock: 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.') 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) 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.') 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) 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): def _run(self,binary,model,projector,audio,language):
process=None;result=dict(state='failed',phase='Spracherkennung fehlgeschlagen. Laufzeit und Speicher prüfen.') 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('<asr_text>')[-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: try:
with self.lock: with self.lock:
if self.cancel.is_set():raise InterruptedError() if self.cancel.is_set():raise InterruptedError()
@@ -134,25 +158,23 @@ class STT:
except OSError:continue except OSError:continue
else: else:
with self.lock:port=self.port;key=self.key 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 return port,key
with self.lock:self.job['phase']='Audio wird auf der CPU transkribiert' except Exception:
boundary='deck-'+uuid.uuid4().hex self.unload_idle();raise
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' def warm(self,profile_id):
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('<asr_text>')[-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
finally:
if result['state']!='complete':self.unload_idle()
with self.lock: with self.lock:
if self.cancel.is_set():result=dict(state='cancelled',phase='Transkription abgebrochen') if self.job and self.job.get('state')=='running':return False
self.job.update(result) 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:
with self.lock:self.warming=False
def unload_idle(self): def unload_idle(self):
with self.lock: with self.lock:
process=self.process;self.process=None;self.port=None;self.key=None;self.binary_path=None process=self.process;self.process=None;self.port=None;self.key=None;self.binary_path=None
+36
View File
@@ -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')
+6 -1
View File
@@ -1,7 +1,7 @@
window.TTSUI=(()=>{ window.TTSUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;} 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 `<section id="tts-panel" class="card"><h2>${setup?'Qwen3-TTS einrichten':'Sprache ausprobieren'}</h2><p id="tts-status" role="status"></p><p id="tts-error" role="alert"></p>${setup?'<p>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.</p><button id="tts-install">TTS-Laufzeit und Modell einrichten</button><button class="secondary" id="tts-install-cancel">Einrichtung abbrechen</button><h3>Vorhandenes TTS-Profil verbinden</h3><p>Ersetzt im ausgewählten Profil die Modellzuordnung durch die eingerichtete CUDA-Variante. Der Profilname bleibt erhalten.</p><select id="tts-profile"></select><button id="tts-assign">CUDA-Modell diesem Profil zuordnen</button>':'<form id="tts-form"><label>Profil<select id="tts-profile" required></select></label><div class="form-grid"><label>Stimme<select id="tts-speaker"></select></label><label>Sprache<select id="tts-language"></select></label></div><label>Text<textarea id="tts-text" rows="5" maxlength="1000" required placeholder="Was soll vorgelesen werden?"></textarea></label><button id="tts-generate">Sprache erzeugen</button><button type="button" id="tts-cancel" class="secondary">Abbrechen</button></form><div id="tts-result"></div><p>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.</p>'}</section>`;} function html(setup=false){return `<section id="tts-panel" class="card"><h2>${setup?'Qwen3-TTS einrichten':'Sprache ausprobieren'}</h2><p id="tts-status" role="status"></p><p id="tts-error" role="alert"></p>${setup?'<p>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.</p><button id="tts-install">TTS-Laufzeit und Modell einrichten</button><button class="secondary" id="tts-install-cancel">Einrichtung abbrechen</button><h3>Vorhandenes TTS-Profil verbinden</h3><p>Ersetzt im ausgewählten Profil die Modellzuordnung durch die eingerichtete CUDA-Variante. Der Profilname bleibt erhalten.</p><select id="tts-profile"></select><button id="tts-assign">CUDA-Modell diesem Profil zuordnen</button><h3>Speicherverhalten</h3><p>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.</p><label>Verhalten<select id="tts-policy"><option value="auto">Automatisch</option><option value="warm">Immer bereit</option><option value="per_request">Nach jeder Anfrage entladen</option></select></label><label>Profil für „Immer bereit“<select id="tts-warm-profile"></select></label><button id="tts-policy-save">Speicherverhalten speichern</button><p id="tts-policy-state" role="status"></p>':'<form id="tts-form"><label>Profil<select id="tts-profile" required></select></label><div class="form-grid"><label>Stimme<select id="tts-speaker"></select></label><label>Sprache<select id="tts-language"></select></label></div><label>Text<textarea id="tts-text" rows="5" maxlength="1000" required placeholder="Was soll vorgelesen werden?"></textarea></label><button id="tts-generate">Sprache erzeugen</button><button type="button" id="tts-cancel" class="secondary">Abbrechen</button></form><div id="tts-result"></div><p>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.</p>'}</section>`;}
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;}; 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'}`; 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;} 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;}} 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});};} 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=>`<option ${v==='Ryan'?'selected':''}>${e(v)}</option>`).join('');el('tts-language').innerHTML=s.runtime.languages.map(v=>`<option ${v==='German'?'selected':''}>${e(v)}</option>`).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');} else{api('tts').then(s=>{if(!root.isConnected)return;el('tts-speaker').innerHTML=s.runtime.speakers.map(v=>`<option ${v==='Ryan'?'selected':''}>${e(v)}</option>`).join('');el('tts-language').innerHTML=s.runtime.languages.map(v=>`<option ${v==='German'?'selected':''}>${e(v)}</option>`).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=>`<option value="${e(p.id)}">${e(p.name)}</option>`).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(); load().catch(x=>error(x.message));refresh();
} }
return {html,bind}; return {html,bind};
+41 -16
View File
@@ -6,9 +6,9 @@ from image_test import probe,cgroup_headroom
WORKER=Path(__file__).with_name('tts_worker.py') WORKER=Path(__file__).with_name('tts_worker.py')
class TTSTests: class TTSTests:
def __init__(self,root,profiles,runtime): 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): 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): 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.'] 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.'] return [] if self.runtime.status()['installed'] else ['TTS-Laufzeit unter TTS → Einrichten installieren.']
@@ -25,6 +25,7 @@ class TTSTests:
release=self.acquire(wait) release=self.acquire(wait)
try: try:
with self.lock: 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.') 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) 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.') 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): def _run(self,ident,text,speaker,language,speed,gpu,release):
directory=self.root/ident;process=None;state='failed';phase='TTS-Ausführung fehlgeschlagen' directory=self.root/ident;process=None;state='failed';phase='TTS-Ausführung fehlgeschlagen'
try: try:
directory.mkdir(mode=0o700);python,model=self.runtime.paths() directory.mkdir(mode=0o700);process=self._ensure(gpu)
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.'))
with self.lock:self.job['phase']='Sprache wird auf der RTX 3060 erzeugt' 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() 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 end=time.monotonic()+600
@@ -66,14 +56,49 @@ class TTSTests:
if not result['ok']:raise ValueError(result['error']) if not result['ok']:raise ValueError(result['error'])
wav=directory/'result.wav' wav=directory/'result.wav'
if not wav.exists() or wav.stat().st_size>32*1024**2:raise ValueError('Ungültige Audioausgabe.') 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 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.' except Exception as exc:phase=str(exc) if isinstance(exc,ValueError) else 'Sprachgenerierung fehlgeschlagen. Laufzeit und Ressourcen prüfen.'
finally: finally:
if state!='complete':self.unload_idle() if state!='complete' or self.policy=='per_request':self.unload_idle()
if state=='complete':(directory/'complete').touch() if state=='complete':(directory/'complete').touch()
with self.lock:self.job.update(state=state,phase=phase,finished_at=time.time()) with self.lock:self.job.update(state=state,phase=phase,finished_at=time.time())
release() 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): def unload_idle(self):
with self.lock: with self.lock:
process=self.process;self.process=None;self.gpu_uuid=None process=self.process;self.process=None;self.gpu_uuid=None