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
+42 -20
View File
@@ -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('<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:
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('<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
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