Add isolated Qwen3 ASR setup test UI and transcription API

This commit is contained in:
Mikei386 committed 2026-09-29 08:54:06 +02:00
1 parent 7bd9096e67
commit 27012165eb
20 files changed
+294 -24

No files matched your search

+6 -2
View File
@@ -47,7 +47,7 @@ Alle Inferenzrouten benötigen `Authorization: Bearer <Deck-API-Token>`.
| POST | `/v1/chat/completions` | Textchat, JSON und SSE-Streaming, Sampling-Defaults aus dem Profil |
| POST | `/v1/images/generations` | Qwen-Image-2.1-Rezept, n=1, `b64_json` |
| POST | `/v1/audio/speech` | Qwen3-TTS CustomVoice, WAV-Ausgabe |
| POST | `/v1/audio/transcriptions` | 501: noch kein eigener STT-Worker |
| POST | `/v1/audio/transcriptions` | Qwen3-ASR, Multipart-WAV → JSON-Transkript |
`model` ist immer der **API-Profilname**, nicht der GGUF-Dateiname. Bildaufträge
verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizite
@@ -55,7 +55,7 @@ verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizi
eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind
deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs.
Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings,
Vision-Eingaben, Bildbearbeitung, Video oder STT-/Musik-/Voice-Worker. TTS/STT benötigen keinen
Vision-Eingaben, Bildbearbeitung, Video oder Musik-/Voice-Worker. TTS/STT benötigen keinen
eigenen Port, sondern eigene Routen und Worker hinter demselben Listener.
Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides
@@ -162,3 +162,7 @@ Profilfreigaben: mehrere Chatprofile, höchstens ein Bildprofil. Die Auswahl ein
### Sprachausgabe
`POST /v1/audio/speech` verwendet ein ausdrücklich freigegebenes TTS-Profil als `model`, `input` (1–1000 Zeichen), optional `voice` (Standard Ryan), `language` (Standard German), `speed` (0,25–4; sonst Profilwert) und `response_format: "wav"`. Antwort: WAV-Datei, kein JSON. MP3 und Streaming sind noch nicht unterstützt. TTS teilt sich die Reservierung mit Chat und Bildern; fremde GPU-Prozesse werden nicht beendet. Das Modell wird nach jeder Ausgabe entladen.
### Spracherkennung
`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; CPU-Worker wird danach beendet. Keine Nutzung des alten Routers.
+4
View File
@@ -208,3 +208,7 @@ Betrieb, Authentifizierung und SSH-Tunnel: [ENDPOINT.md](ENDPOINT.md).
### Optionale TTS-Laufzeit
Nach der Grundinstallation wird Qwen3-TTS über Einstellungen → Laufzeiten → Text-to-Speech installiert. Der Installer liefert den Installationscode und die Versionsbindung mit; die große CUDA-Umgebung und Modellgewichte werden erst auf Klick im eigenen Deck-Zustand geladen. Voraussetzung ist ein funktionierender NVIDIA-Treiber mit GPU-Zugriff für Deck. Es werden keine System-Python-Pakete ersetzt. Aktuell wird für die Ausführung eine freie RTX 3060 vorausgesetzt.
### Optionale Spracherkennung
STT verwendet die aktive llama.cpp-Laufzeit auf der CPU. Diese bei einer frischen Installation zunächst über die Build-Verwaltung erstellen und aktivieren. STT → Einrichten lädt danach das geprüfte Qwen3-ASR-Modell samt Audio-Projektor über die Downloadwarteschlange. Es sind keine zusätzlichen Python- oder CUDA-Pakete nötig. Der Installer liefert Worker und Oberfläche mit.
+1 -1
View File
@@ -17,7 +17,7 @@ Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose.
Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. Qwen3-TTS CustomVoice ist über eine eigene CUDA-Laufzeit ausführbar; STT, Musik, Voice und Video bleiben vorbereitet. Sprachmodelle besitzen außerdem einen internen Testchat mit Abbruch und Speicherdiagnose. Details: [Modellverwaltung](STUDIO.md).
Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. Qwen3-TTS CustomVoice ist über eine eigene CUDA-Laufzeit ausführbar; Qwen3-ASR bietet Spracherkennung; Musik, Voice und Video bleiben vorbereitet. Sprachmodelle besitzen außerdem einen internen Testchat mit Abbruch und Speicherdiagnose. Details: [Modellverwaltung](STUDIO.md).
## Zugang und API-Token
+12 -2
View File
@@ -222,7 +222,7 @@ Verifikation auf Athena: eigene ungespeicherte Testprofilkopie, 512×512, 4 Schr
Audio ist in Text-to-Speech (`audio`, bestehende Dateien und Links bleiben erhalten), Spracherkennung (`stt`), Musik (`music`) und Stimme / Voice (`voice`) aufgeteilt. Jede Kategorie hat eigene Suchergebnisse, Bibliothek, Downloads und vorbereitende Profile. Hub-Filter: text-to-speech, automatic-speech-recognition, text-to-audio, audio-to-audio. Musik umfasst gemäß Hub-Task auch Geräuscherzeugung; Voice umfasst auch Audiotrennung. Direkte Repository-Links umgehen wie bisher den Suchfilter.
Neue Downloads behalten die gewählte Kategorie. Alte Audio-Dateien bleiben bei TTS; es erfolgt keine ungesicherte automatische Umklassifizierung. TTS unterstützt jetzt die unten beschriebene CustomVoice-Laufzeit; STT, Musik und Voice speichern zunächst nur Modell und Namen. Eigenständige Studio-Oberflächen bleiben Weitere Dienste.
Neue Downloads behalten die gewählte Kategorie. Alte Audio-Dateien bleiben bei TTS; es erfolgt keine ungesicherte automatische Umklassifizierung. TTS unterstützt jetzt die unten beschriebene CustomVoice-Laufzeit; Musik und Voice speichern zunächst nur Modell und Namen; STT unterstützt die unten dokumentierte Qwen3-ASR-Variante. Eigenständige Studio-Oberflächen bleiben Weitere Dienste.
### Qwen3-TTS einrichten und testen
@@ -238,8 +238,18 @@ Interne authentifizierte API: `GET /api/v1/tts`, `GET /api/v1/tts/audio?id=…`,
### Gemeinsame Laufzeitprüfung
Dateiauswahl unter Entdecken, Bibliothek und Profile zeigen jetzt einen gemeinsamen Einrichtungsstatus. Die lesende Zuordnung in `execution_setup.py` verwendet die vorhandenen Bildrezepte, die festgelegte CustomVoice-Revision und Chat-GGUF als llama.cpp-Kandidaten. Ein Chat-GGUF ist ohne Ladetest keine Zusage über Architekturunterstützung. STT, Musik, Voice, Video und unbekannte Bild-/TTS-Varianten bleiben ausdrücklich nicht unterstützt.
Dateiauswahl unter Entdecken, Bibliothek und Profile zeigen jetzt einen gemeinsamen Einrichtungsstatus. Die lesende Zuordnung in `execution_setup.py` verwendet die vorhandenen Bildrezepte, die festgelegte CustomVoice-Revision und Chat-GGUF als llama.cpp-Kandidaten. Ein Chat-GGUF ist ohne Ladetest keine Zusage über Architekturunterstützung. Musik, Voice, Video und unbekannte Bild-/TTS-/STT-Varianten bleiben ausdrücklich nicht unterstützt.
„Ausführung einrichten“ führt zum passenden vorhandenen Installer. Ist die Umgebung installiert, wird sie wiederverwendet und „Einrichtung ansehen“ angeboten. Bei Bildprofilen führt „Komponenten“ zu Textencoder-/VAE-Download und Zuordnung. TTS installiert sein vollständiges Modellpaket; ein alternatives TTS-Modell wird nie stillschweigend ersetzt. Konfigurationen, Projektoren, Encoder und VAE sind Zusatzdateien ohne eigenen Worker. Es werden weder beliebiger Installationscode gesucht noch Modelle automatisch gestartet.
Die Verwaltungsantworten für Katalogdateien, Bibliothek und Profile enthalten `execution` mit Zustand, Erklärung, Laufzeit, Installationsstatus und festem Einrichtungslink. `/api/v1/catalog/files` akzeptiert dafür `kind`. Speicherpassung bleibt eine separate Prüfung. Der Download nicht unterstützter Modelle bleibt für spätere Verwendung möglich.
### STT: Qwen3-ASR mit wiederverwendeter llama.cpp-Laufzeit
Unter **Spracherkennung (STT) → Einrichten** oder **Einstellungen → Laufzeiten → Spracherkennung** werden Qwen3-ASR-0.6B-Q8_0.gguf und der Audio-Projektor aus [ggml-org](https://huggingface.co/ggml-org/Qwen3-ASR-0.6B-GGUF) (Revision `928ab958557df9aa2ef1c93e0e83c7ad0933fae2`) heruntergeladen. Vorhandene Dateien werden wiederverwendet. Die vorhandene aktive llama.cpp-Laufzeit wird verwendet; auf frischer Installation führt der Link zu deren Build-Verwaltung. Es wird kein zusätzlicher Docker-Dienst benötigt.
Die handy-computer-Konvertierung verwendet `qwen3_asr`, das der auf Athena getestete Build nicht versteht. Sie bleibt erhalten und wird nicht als ausführbar ausgegeben. Im Einrichtungsdialog lässt sich ein bestehendes Profil ausdrücklich auf die geprüfte ggml-org-Variante umstellen; alternativ ein neues Profil aus der Bibliothek anlegen. Der dazugehörige Audio-Projektor wird anhand fester Quelle, Revision und Datei automatisch gewählt.
**STT → Testen**: Profil wählen, Audiodatei auswählen, Sprache (Deutsch, Englisch, automatisch) wählen und transkribieren. Der Browser dekodiert maximal 20 MiB / 120 Sekunden und wandelt in PCM16-Mono-WAV bei 16 kHz um. Eingabeformate hängen von den Browser-Codecs ab. Abbrechen beendet ausschließlich den eigenen STT-Prozess. CPU-Ausführung mit zwei Threads, keine GPU-Belegung; mindestens 4 GiB freier Deck-RAM werden vorausgesetzt. Ein API-Schlüssel schützt den kurzlebigen internen Loopback-Worker. Audio und Transkript werden nicht persistiert; das letzte Transkript liegt bis zum nächsten Auftrag oder Neustart im Speicher und ist nach Anmeldung einsehbar. Keine Mikrofonaufnahme, Zeitstempel oder Sprechertrennung in dieser Version.
Interne API: `GET /api/v1/stt`, `POST /api/v1/stt/setup` (`{}`), `/assign` (`id`, `revision`), `/start` (Multipart: `profile_id`, `file`, optional `language`), `/cancel` (`{}`). `/setup` verwendet die normale Downloadwarteschlange und startet kein Modell. Ausführung setzt ein vollständiges Profil voraus.
+1 -1
View File
@@ -8,7 +8,7 @@ const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</stron
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
if(page==='access'){await accessPage();return;}
if(page==='network'){await networkPage();return;}
if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return;
+1 -1
View File
@@ -40,7 +40,7 @@ def file_role(filename):
"""One classification shared by library UI and profile validation."""
name=filename.lower();parts=PurePosixPath(name).parts
if re.search(r'(?:^|[/_-])mmproj(?:[/_.-]|$)',name):
role,label='vision_projector','Vision-Projektor'
role,label=('audio_projector','Audio-Projektor') if 'qwen3-asr' in name else ('vision_projector','Vision-Projektor')
elif 'speech_tokenizer' in parts:
role,label='auxiliary','Sprach-Tokenizer'
elif any(p in ('text_encoder','text_encoders') for p in parts) or re.search(r'(?:^|[/_-])(?:text.encoder|qwen3vl|t5xxl|clip_l|clip_g)(?:[/_.-]|$)',name):
+2 -2
View File
@@ -13,8 +13,8 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py /app/
COPY tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
COPY stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py /app/
COPY stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
+1 -1
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['deploy/tts-requirements.lock','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','catalog.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 = ['deploy/tts-requirements.lock','stt.py','stt-ui.js','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','catalog.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):
+22 -3
View File
@@ -10,6 +10,7 @@ import threading
import time
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from inference import InferenceError
from stt import read_upload
class APIError(ValueError):
def __init__(self,message,status=400,code='invalid_request_error'):
@@ -17,7 +18,7 @@ class APIError(ValueError):
class Endpoint:
def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port):
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.credentials=credentials
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.stt=None;self.credentials=credentials
self.management_port=management_port;self.lock=threading.RLock();self.http=None;self.thread=None;self.state='stopped';self.error=None;self.inflight=0
self.allowed_ports=[int(p) for p in os.environ.get('DECK_API_PORTS','').split(',') if p]
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
@@ -38,7 +39,7 @@ class Endpoint:
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt')]:
subset=[p for p in rows if p['kind']==kind]
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status()['job'] and self.tts.status()['job']['state']=='running') if kind=='audio' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None))
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status()['job'] and self.tts.status()['job']['state']=='running') if kind=='audio' else bool(self.stt and self.stt.status()['job'] and self.stt.status()['job']['state']=='running') if kind=='stt' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None) or (kind=='stt' and self.stt is not None))
scheduler_status=self.scheduler.status()
with self.lock:return dict(state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
def configure(self,data):
@@ -100,6 +101,7 @@ class Endpoint:
with self.lock:self.state='stopping';http=self.http
if http:http.shutdown();http.server_close()
if self.tts:self.tts.stop()
if self.stt:self.stt.stop()
self.images.stop();self.worker.stop()
def allowed(self):
with self.lock:return self.state=='running'
@@ -152,7 +154,7 @@ class APIHandler(BaseHTTPRequestHandler):
if self.command=='GET' and self.path=='/v1/models':return self.send(ep.model_list())
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
if self.command!='POST':raise APIError('Route nicht gefunden.',404)
if self.path=='/v1/audio/transcriptions':raise APIError('Für TTS/STT ist noch keine Deck-Laufzeit eingerichtet.',501,'not_implemented')
if self.path=='/v1/audio/transcriptions':return self.transcription(ep)
if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech'):raise APIError('Route nicht implementiert.',404)
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
try:length=int(self.headers.get('Content-Length','0'))
@@ -174,6 +176,23 @@ class APIHandler(BaseHTTPRequestHandler):
self.close_connection=True
if admitted:
with ep.lock:ep.inflight-=1
def transcription(self,ep):
if not ep.stt:raise APIError('STT-Laufzeit nicht eingerichtet.',501,'not_implemented')
try:
fields,audio=read_upload(self)
if 'profile_id' in fields:raise ValueError('API-Profilname als model erforderlich.')
profile=ep.find_profile(fields.get('model'),'stt')
job=ep.stt.start(profile['id'],audio,fields.get('language','de'))
except APIError:raise
except ValueError as exc:raise APIError(str(exc)) from None
deadline=time.monotonic()+280
while time.monotonic()<deadline:
current=ep.stt.status()['job']
if not current or current['id']!=job['id']:raise APIError('STT-Ergebnis nicht mehr verfügbar.',409)
if current['state']=='complete':return self.send({'text':current['text']})
if current['state']!='running':raise APIError(current['phase'],503)
time.sleep(.2)
ep.stt.stop();raise APIError('STT-Zeitlimit überschritten.',504)
def speech(self,ep,data):
if not ep.tts:raise APIError('TTS-Laufzeit nicht eingerichtet.',501)
if set(data)-{'model','input','voice','response_format','speed','language'}:raise APIError('Nicht unterstützte TTS-Felder.')
+6 -1
View File
@@ -1,5 +1,6 @@
"""Read-only, conservative mapping to Deck's maintained execution adapters."""
from catalog import file_role
from stt import supported as stt_supported
from profiles import image_recipe
from tts_runtime import REPO, REVISION
@@ -9,12 +10,16 @@ def assess(model, installed, profile=None):
if not file_role(filename)['profile_eligible']:
result.update(state='component',label='Zusatzdatei',message='Diese Datei wird einem kompatiblen Modellprofil zugeordnet und benötigt keine eigene Laufzeit.')
return result
if kind=='image' and image_recipe(model):
if kind=='stt' and stt_supported(model):
result.update(runtime='llama.cpp · Qwen3-ASR auf CPU',route='#stt-runtime',components=['Passender Audio-Projektor (unter Einrichten herunterladen)'])
elif kind=='image' and image_recipe(model):
result.update(runtime='ComfyUI + ComfyUI-GGUF',route='#image-runtime',components=[x['label'] for x in image_recipe(model).values()])
elif kind=='audio' and repo==REPO and filename=='model.safetensors' and model.get('revision')==REVISION:
result.update(runtime='Qwen3-TTS CustomVoice',route='#tts-runtime',components=['Vollständiges Modellpaket und Sprach-Tokenizer (im Einrichtungsassistenten enthalten)'])
elif kind=='chat' and filename.lower().endswith('.gguf'):
result.update(runtime='llama.cpp',route='#runtime',state='check',label='Kompatibilität beim Laden prüfen',message='llama.cpp ist der mögliche Ausführungsweg für dieses Chat-GGUF. Architektur und gewählte Optionen müssen vom installierten Build unterstützt werden. Der Testchat prüft die tatsächliche Ausführung.')
elif kind=='stt':
result.update(message='Für diese STT-Datei fehlt die Anbindung. Die geprüfte llama.cpp-Variante von Qwen3-ASR kann unter Einrichten zusätzlich geladen und ausdrücklich einem Profil zugeordnet werden.',alternative=dict(route='#stt-runtime',label='Unterstützte STT-Alternative einrichten (Modellwechsel)'))
elif kind=='audio':
result.update(message='Dieses TTS-Modell wird noch nicht ausgeführt. Deck unterstützt derzeit die festgelegte Qwen3-TTS-CustomVoice-Variante mit eingebauten Stimmen. MLX, Base mit Referenzstimme und andere TTS-Architekturen benötigen andere Anbindungen.',alternative=dict(route='#tts-runtime',label='Unterstützte TTS-Alternative ansehen (Modellwechsel)'))
if not result['runtime']:return result
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt &amp; Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt &amp; Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
+1 -1
View File
@@ -12,7 +12,7 @@ import sys
NAME = 'athena-deck-network'
BASE = Path('/opt/athena-deck')
FILES = {'deploy/tts-requirements.lock','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','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
FILES = {'deploy/tts-requirements.lock','stt.py','stt-ui.js','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','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
+1 -1
View File
@@ -8,7 +8,7 @@ window.ProfilesUI=(()=>{
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':kind==='audio'?'Eingerichtete TTS-Profile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
+2 -1
View File
@@ -55,7 +55,7 @@ class Profiles:
def __init__(self,path,catalog):
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
self.image_runtime_ready=lambda:False
self.chat_blockers=None;self.tts_blockers=None
self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
def status(self):
with self.lock:
@@ -74,6 +74,7 @@ class Profiles:
if p.get('model') and image_recipe(p['model']) and self.image_runtime_ready():
p['blockers']=[b for b in p['blockers'] if not b.startswith('Eine eigene Bildlaufzeit')]
if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p)
if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p)
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p)
if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.')
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
+23 -3
View File
@@ -9,6 +9,7 @@ from http.cookies import SimpleCookie, CookieError
from auth import CredentialStore, verify_password
from catalog import Catalog
from tts_runtime import TTSRuntime
from stt import STT,read_upload
from tts_test import TTSTests
from profiles import Profiles
from capacity import assess, overview
@@ -93,8 +94,11 @@ class Server(ThreadingHTTPServer):
self.profiles.chat_blockers=self.worker.blockers
self.image_tests.acquire=self.scheduler.image_reservation
self.tts_tests.acquire=self.scheduler.image_reservation
self.stt=STT(self.profiles,self.catalog,self.runtime)
self.profiles.stt_blockers=self.stt.blockers
self.endpoint=Endpoint(self.catalog.root.parent,self.profiles,self.worker,self.scheduler,self.image_tests,self.credentials,self.server_port)
self.endpoint.tts=self.tts_tests
self.endpoint.stt=self.stt
if self.endpoint.config['autostart']:
try:self.endpoint.start()
except ValueError as exc:self.endpoint.error=str(exc)
@@ -241,7 +245,7 @@ class Handler(BaseHTTPRequestHandler):
return self.headers.get('Host') in allowed
def execution_setup(self, model, profile=None):
installed={'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False)}
installed={'stt':bool(self.server.runtime.status().get('active')),'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False)}
return execution_assess(model,installed,profile)
def do_GET(self):
@@ -255,7 +259,7 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
routes = {'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
routes = {'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
if self.path in routes:
name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime)
@@ -267,6 +271,7 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/chat-tests':return self.respond(self.server.chat_tests.status())
if self.path == '/api/v1/endpoint':return self.respond(self.server.endpoint.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/tts':return self.respond(self.server.tts_tests.status())
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')
@@ -385,6 +390,18 @@ class Handler(BaseHTTPRequestHandler):
return self.respond(ep.start() if self.path.endswith('/start') else ep.stop())
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Endpunkt konnte nicht konfiguriert werden.'},503)
if self.path in ('/api/v1/stt/start','/api/v1/stt/setup','/api/v1/stt/assign','/api/v1/stt/cancel'):
try:
if self.path.endswith('/start'):
fields,audio=read_upload(self)
if 'model' in fields or not fields.get('profile_id'):raise ValueError('Profil-ID erforderlich.')
return self.respond(self.server.stt.start(fields['profile_id'],audio,fields.get('language','de')))
data=self.read_json()
if self.path.endswith('/assign') and set(data)=={'id','revision'}:return self.respond(self.server.stt.assign(data['id'],data['revision']))
if data:raise ValueError('Keine Parameter erwartet.')
return self.respond(self.server.stt.setup() if self.path.endswith('/setup') else self.server.stt.stop())
except ValueError as exc:return self.respond({'error':str(exc)},400)
except Exception:return self.respond({'error':'STT-Aktion fehlgeschlagen.'},503)
if self.path in ('/api/v1/tts/install','/api/v1/tts/install-cancel','/api/v1/tts/assign','/api/v1/tts/start','/api/v1/tts/cancel'):
try:
data=self.read_json();t=self.server.tts_tests
@@ -421,7 +438,9 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/profiles/delete':
try:
data=self.read_json()
with self.server.image_tests.lock, self.server.tts_tests.lock:
with self.server.image_tests.lock, self.server.tts_tests.lock, self.server.stt.lock:
stt_job=self.server.stt.job
if stt_job and stt_job.get("state")=="running" and stt_job.get("profile_id")==data.get("id"):raise ValueError("Dieses STT-Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.")
tts_job=self.server.tts_tests.job
if tts_job and tts_job.get("state")=="running" and tts_job.get("profile_id")==data.get("id"):raise ValueError("Dieses TTS-Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.")
job=self.server.image_tests.job
@@ -481,6 +500,7 @@ def main():
server.auto_tests.stop()
server.chat_tests.stop()
server.endpoint.close()
server.stt.stop()
server.tts_tests.stop()
server.tts_runtime.stop()
server.image_runtime.stop()
+12
View File
@@ -0,0 +1,12 @@
window.STTUI=(()=>{
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;}
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>`;}
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=[];
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.';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;}};}
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();
}
return {html,bind};
})();
+158
View File
@@ -0,0 +1,158 @@
"""Owned CPU-only Qwen3-ASR worker. Audio and transcript live only in memory."""
import io,json,os,secrets,signal,socket,subprocess,threading,time,uuid,wave,urllib.request
from email import policy
from email.parser import BytesParser
from pathlib import Path
from image_test import cgroup_headroom
REPO='ggml-org/Qwen3-ASR-0.6B-GGUF'
REVISION='928ab958557df9aa2ef1c93e0e83c7ad0933fae2'
MODEL='Qwen3-ASR-0.6B-Q8_0.gguf'
PROJECTOR_REPO='ggml-org/Qwen3-ASR-0.6B-GGUF'
PROJECTOR_REVISION='928ab958557df9aa2ef1c93e0e83c7ad0933fae2'
PROJECTOR='mmproj-Qwen3-ASR-0.6B-Q8_0.gguf'
MAX_AUDIO=8*1024*1024
def supported(m):return m.get('repo')==REPO and m.get('revision')==REVISION and m.get('file')==MODEL
def validate_wav(audio):
if not isinstance(audio,bytes) or not 44<=len(audio)<=MAX_AUDIO:raise ValueError('WAV-Datei bis 8 MiB erforderlich.')
try:
with wave.open(io.BytesIO(audio)) as w:
if w.getnchannels()!=1 or w.getsampwidth()!=2 or w.getframerate()!=16000 or not 0<w.getnframes()<=120*16000:raise ValueError('WAV muss PCM16, mono, 16 kHz und höchstens 120 Sekunden lang sein.')
if len(w.readframes(w.getnframes()))!=w.getnframes()*2:raise ValueError('WAV-Datei ist unvollständig.')
except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei.') from None
def read_upload(handler):
handler.connection.settimeout(30)
if handler.headers.get('Transfer-Encoding'):raise ValueError('Chunked Upload wird nicht unterstützt.')
try:length=int(handler.headers.get('Content-Length','0'))
except ValueError:raise ValueError('Ungültige Upload-Länge.') from None
content_type=handler.headers.get('Content-Type','')
if not 0<length<=MAX_AUDIO+65536 or not content_type.lower().startswith('multipart/form-data;'):raise ValueError('Multipart-Upload bis 8 MiB erforderlich.')
raw=handler.rfile.read(length)
if len(raw)!=length:raise ValueError('Upload unvollständig.')
message=BytesParser(policy=policy.default).parsebytes(b'Content-Type: '+content_type.encode()+b'\r\nMIME-Version: 1.0\r\n\r\n'+raw)
if not message.is_multipart() or message.defects:raise ValueError('Ungültiger Multipart-Upload.')
fields={};audio=None
for part in message.iter_parts():
name=part.get_param('name',header='content-disposition');data=part.get_payload(decode=True)
if not isinstance(data,bytes):raise ValueError('Ungültiges Upload-Feld.')
if name=='file':
if audio is not None:raise ValueError('Nur eine Audiodatei erlaubt.')
audio=data
else:
if name not in ('model','profile_id','language','response_format') or name in fields or len(data)>256:raise ValueError('Ungültiges oder doppeltes Feld.')
try:fields[name]=data.decode('utf-8')
except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None
validate_wav(audio)
if fields.get('response_format','json')!='json':raise ValueError('Aktuell wird response_format=json unterstützt.')
return fields,audio
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.cancel=threading.Event()
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.')
directory=self.runtime.root/ident;binary=directory/'build/bin/llama-server'
if not binary.is_file():raise ValueError('Aktive llama.cpp-Laufzeit fehlt.')
return binary
def projector(self):
for x in self.catalog.status()['entries']:
if x['repo']==PROJECTOR_REPO and x['revision']==PROJECTOR_REVISION and x['file']==PROJECTOR:return self.catalog.entry(x['id'])
raise ValueError('Audio-Projektor fehlt. Unter STT → Einrichten herunterladen.')
def blockers(self,p):
if not supported(p['model']):return ['Diese STT-Variante ist noch nicht angebunden. Unterstützt wird Qwen3-ASR 0.6B Q8_0 aus dem geprüften Repository.']
errors=[]
for f in [self.build,self.projector]:
try:f()
except ValueError as exc:errors.append(str(exc))
return errors
def status(self):
try:self.build();installed=True
except ValueError:installed=False
try:self.projector();projector=True
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)
def model_entry(self):
for x in self.catalog.status()['entries']:
if supported(x):return self.catalog.entry(x['id'])
raise ValueError('Passende llama.cpp-Modellvariante fehlt. Unter STT → Einrichten herunterladen.')
def assign(self,ident,revision):
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Zuerst den STT-Auftrag beenden.')
p=next((p for p in self.profiles.status()['profiles'] if p['id']==ident and p['kind']=='stt'),None)
if not p:raise ValueError('STT-Profil nicht gefunden.')
return self.profiles.save(dict(id=ident,revision=revision,name=p['name'],kind='stt',model_id=self.model_entry()['id'],parameters=p['parameters']))
def setup(self):
data=self.catalog.files(REPO)
if data['revision']!=REVISION:raise ValueError('Quellversion hat sich geändert. Das Komponentenrezept muss zuerst geprüft werden.')
queued=[]
for filename in (MODEL,PROJECTOR):
if any(x['repo']==REPO and x['revision']==REVISION and x['file']==filename for x in self.catalog.status()['entries']):continue
if any(x.get('repo')==REPO and x.get('file')==filename and x['state'] in ('downloading','queued') for x in self.catalog.status().get('downloads',[])):continue
queued.append(self.catalog.start(REPO,filename,REVISION,'stt'))
return dict(queued=len(queued))
def start(self,profile_id,audio,language='de'):
validate_wav(audio)
if language not in ('de','en','auto'):raise ValueError('Sprache muss de, en oder auto sein.')
with self.lock:
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.')
headroom=cgroup_headroom()
if headroom is not None and headroom<4*1024**3:raise ValueError('Mindestens 4 GiB freier Deck-RAM werden benötigt.')
binary=self.build();model=self.catalog.root/p['model_id']/'model.gguf';projector=self.catalog.root/self.projector()['id']/'model.gguf'
self.cancel.clear();ident=uuid.uuid4().hex;self.job=dict(id=ident,state='running',phase='Spracherkennung lädt auf der CPU',profile_id=profile_id)
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:
key=secrets.token_hex(24)
with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
env=dict(os.environ,CUDA_VISIBLE_DEVICES='',OMP_NUM_THREADS='2')
args=[str(binary),'--model',str(model),'--mmproj',str(projector),'--no-mmproj-offload','--n-gpu-layers','0','--ctx-size','4096','--threads','2','--parallel','1','--host','127.0.0.1','--port',str(port),'--no-ui','--fit','off','--alias','deck-stt','--api-key',key]
with self.lock:
if self.cancel.is_set():raise InterruptedError()
process=subprocess.Popen(args,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True);self.process=process
base=f'http://127.0.0.1:{port}';headers={'Authorization':'Bearer '+key};deadline=time.monotonic()+90
while True:
if self.cancel.wait(.3):raise InterruptedError()
if process.poll() is not None:raise ValueError('llama.cpp konnte das ASR-Modell nicht laden. Build-Unterstützung und freien RAM prüfen.')
if time.monotonic()>deadline:raise ValueError('Zeitlimit beim Laden des ASR-Modells.')
try:
with urllib.request.urlopen(urllib.request.Request(base+'/health',headers=headers),timeout=1) as response:
if response.status==200:break
except OSError:continue
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',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 process and process.poll() is None:
try:
os.killpg(process.pid,signal.SIGTERM)
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
except ProcessLookupError:pass
with self.lock:
if self.cancel.is_set():result=dict(state='cancelled',phase='Transkription abgebrochen')
self.process=None;self.job.update(result)
def stop(self):
with self.lock:
self.cancel.set()
if self.process and self.process.poll() is None:
try:os.killpg(self.process.pid,signal.SIGTERM)
except ProcessLookupError:pass
return dict(cancellation_requested=True)
+5 -3
View File
@@ -7,14 +7,16 @@ const Studio=(()=>{
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';}
if(modelId)section='profiles';
const body=page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&page!=='audio'?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='audio'?[['setup','Einrichten']]:[]),...(['image','chat','audio'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='setup'?TTSUI.html(true):['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
if(page==='stt-runtime'){STTUI.bind(true);return;}
if(page==='tts-runtime'){TTSUI.bind(true);return;}
if(page==='runtime'){RuntimeUI.bind();return;}
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
if(page==='runtimes')return;
if(section==='setup')TTSUI.bind(true);
if(section==='setup'){if(page==='stt')STTUI.bind(true);else TTSUI.bind(true);}
else if(['test','running'].includes(section)&&page==='stt')STTUI.bind();
else if(['test','running'].includes(section)&&page==='audio')TTSUI.bind();
else if(section==='auto')AutoTestUI.bind();
else if(section==='test'&&page==='chat')ChatTestUI.bind();
@@ -24,7 +26,7 @@ const Studio=(()=>{
else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads');
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
}
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten werden einmal eingerichtet und für kompatible Modelle wiederverwendet. Entdecken, Bibliothek und Profile zeigen den passenden Einrichtungsweg; unbekannte Modelle werden als noch nicht unterstützt gekennzeichnet. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>Text-to-Speech</h2><p>Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.</p><a class="link" href="#tts-runtime">TTS einrichten →</a></section><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section><section class="card"><h2>Docker</h2><p>Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.</p><a class="link" href="#docker-runtime">Docker öffnen →</a></section></div>`;}
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten werden einmal eingerichtet und für kompatible Modelle wiederverwendet. Entdecken, Bibliothek und Profile zeigen den passenden Einrichtungsweg; unbekannte Modelle werden als noch nicht unterstützt gekennzeichnet. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>Spracherkennung</h2><p>Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.</p><a class="link" href="#stt-runtime">STT einrichten →</a></section><section class="card"><h2>Text-to-Speech</h2><p>Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.</p><a class="link" href="#tts-runtime">TTS einrichten →</a></section><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section><section class="card"><h2>Docker</h2><p>Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.</p><a class="link" href="#docker-runtime">Docker öffnen →</a></section></div>`;}
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF und das unterstützte FLUX.2-Klein-9B-Modell. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.</p><p>Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Bei großen FLUX-Gewichten wird zusätzlich System-RAM genutzt. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile};
+10
View File
@@ -55,6 +55,16 @@ class EndpointTests(unittest.TestCase):
with self.assertRaises(ValueError):self.enable('audio')
self.assertEqual(self.request('/v1/audio/speech',{})[0],501)
self.record['api_token_hash']=hashlib.sha256(b'B'*32).hexdigest();self.assertEqual(self.request()[0],401);self.assertEqual(self.request(token='B'*32)[0],200)
def test_stt_endpoint_multipart_and_publication(self):
from test_stt import audio
self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={}))
self.ep.stt=Mock();self.ep.stt.start.return_value={'id':'s'};self.ep.stt.status.return_value={'job':dict(id='s',state='complete',text='Testaufnahme')}
body=b'--x\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\n\r\n'+audio()+b'\r\n--x\r\nContent-Disposition: form-data; name="model"\r\n\r\nstt\r\n--x--\r\n'
def request():
c=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);c.request('POST','/v1/audio/transcriptions',body,{'Content-Type':'multipart/form-data; boundary=x','Authorization':'Bearer '+'A'*32});r=c.getresponse();result=r.status,json.loads(r.read());c.close();return result
self.assertEqual(request()[0],404)
self.enable('stt');self.assertEqual(request(),(200,{'text':'Testaufnahme'}))
self.assertEqual(self.ep.stt.start.call_args.args[0],'stt')
def test_tts_endpoint_returns_wav_and_requires_publication(self):
self.rows[-1].update(runnable=True,blockers=[])
self.ep.tts=Mock();self.ep.tts.start.return_value={'id':'a'*32};self.ep.tts.status.return_value={'job':{'id':'a'*32,'state':'complete'}};self.ep.tts.audio.return_value=b'RIFF-test-WAVE'
+25
View File
@@ -0,0 +1,25 @@
import io,unittest,wave
from types import SimpleNamespace
from stt import validate_wav,read_upload,supported,REPO,REVISION,MODEL,STT
def audio():
out=io.BytesIO()
with wave.open(out,'wb') as w:w.setnchannels(1);w.setsampwidth(2);w.setframerate(16000);w.writeframes(b'\0\0'*1600)
return out.getvalue()
class STTTests(unittest.TestCase):
def test_wav(self):
validate_wav(audio())
for b in [b'',b'bad',audio()[:-2]]:
with self.assertRaises(ValueError):validate_wav(b)
def test_recipe(self):
self.assertTrue(supported(dict(repo=REPO,revision=REVISION,file=MODEL)))
self.assertFalse(supported(dict(repo=REPO,revision='other',file=MODEL)))
def test_multipart(self):
body=b'--x\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\n\r\n'+audio()+b'\r\n--x\r\nContent-Disposition: form-data; name="model"\r\n\r\nASR\r\n--x--\r\n'
h=SimpleNamespace(headers={'Content-Type':'multipart/form-data; boundary=x','Content-Length':str(len(body))},rfile=io.BytesIO(body),connection=SimpleNamespace(settimeout=lambda n:None))
fields,data=read_upload(h);self.assertEqual(fields['model'],'ASR');validate_wav(data)
h.headers['Transfer-Encoding']='chunked'
with self.assertRaises(ValueError):read_upload(h)
def test_missing_runtime(self):
s=STT(None,None,SimpleNamespace(status=lambda:{'active':None}))
with self.assertRaises(ValueError):s.build()