From 27012165ebd5c1b95979b9211a5dab8d117e5822 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:54:06 +0200 Subject: [PATCH] Add isolated Qwen3 ASR setup test UI and transcription API --- ENDPOINT.md | 8 +- INSTALL.md | 4 + README.md | 2 +- STUDIO.md | 14 +++- app.js | 2 +- catalog.py | 2 +- deploy/Dockerfile | 4 +- deploy/install.py | 2 +- endpoint.py | 25 +++++- execution_setup.py | 7 +- index.html | 2 +- network/install_remote.py | 2 +- profiles-ui.js | 2 +- profiles.py | 3 +- server.py | 26 ++++++- stt-ui.js | 12 +++ stt.py | 158 ++++++++++++++++++++++++++++++++++++++ studio.js | 8 +- test_endpoint.py | 10 +++ test_stt.py | 25 ++++++ 20 files changed, 294 insertions(+), 24 deletions(-) create mode 100644 stt-ui.js create mode 100644 stt.py create mode 100644 test_stt.py diff --git a/ENDPOINT.md b/ENDPOINT.md index fe92676..526b1a2 100644 --- a/ENDPOINT.md +++ b/ENDPOINT.md @@ -47,7 +47,7 @@ Alle Inferenzrouten benötigen `Authorization: Bearer `. | 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. diff --git a/INSTALL.md b/INSTALL.md index 43f6d48..b797544 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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. diff --git a/README.md b/README.md index 0f0c8df..9776e54 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/STUDIO.md b/STUDIO.md index 3006696..864ea54 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -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. diff --git a/app.js b/app.js index 4217e70..1675ed6 100644 --- a/app.js +++ b/app.js @@ -8,7 +8,7 @@ const metric=(title,value)=>`
${title}${value}v==null?'':``; 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; diff --git a/catalog.py b/catalog.py index 657eff4..e3df169 100644 --- a/catalog.py +++ b/catalog.py @@ -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): diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 135dbb9..92fc387 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 \ diff --git a/deploy/install.py b/deploy/install.py index a6c2614..0985efe 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -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): diff --git a/endpoint.py b/endpoint.py index 7d4e002..c0d7e9f 100644 --- a/endpoint.py +++ b/endpoint.py @@ -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()Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
+Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
diff --git a/network/install_remote.py b/network/install_remote.py index db9c50b..7d7f701 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -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) diff --git a/profiles-ui.js b/profiles-ui.js index 276353f..9e2570d 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -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=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${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(' · ')}

${CatalogUI.executionHTML(p.execution)}
${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; - el('profile-list').insertAdjacentHTML('afterbegin',`

${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.

`); + el('profile-list').insertAdjacentHTML('afterbegin',`

${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.

`); 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))); diff --git a/profiles.py b/profiles.py index 0aed3b8..f448af1 100644 --- a/profiles.py +++ b/profiles.py @@ -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' diff --git a/server.py b/server.py index e6dc794..12e9425 100644 --- a/server.py +++ b/server.py @@ -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() diff --git a/stt-ui.js b/stt-ui.js new file mode 100644 index 0000000..aac22f1 --- /dev/null +++ b/stt-ui.js @@ -0,0 +1,12 @@ +window.STTUI=(()=>{ + const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + async function api(path,data){const response=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'X-Athena-Deck':'1',...(data instanceof FormData?{}:{'Content-Type':'application/json'})},body:data instanceof FormData?data:JSON.stringify(data)});const result=await response.json();if(!response.ok)throw Error(result.error||'STT-Anfrage fehlgeschlagen');return result;} + function html(setup=false){return `

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

${setup?'

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

llama.cpp installieren / Build auswählen →

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

Vorhandenes Profil umstellen

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

':'

Transkript

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

'}
`;} + async function wav(file){if(file.size>20*1024**2)throw Error('Datei ist größer als 20 MiB.');const context=new AudioContext();let decoded;try{decoded=await context.decodeAudioData(await file.arrayBuffer());}finally{await context.close();}if(!decoded.duration||decoded.duration>120)throw Error('Maximal 120 Sekunden Audio erlaubt.');const offline=new OfflineAudioContext(1,Math.ceil(decoded.duration*16000),16000),source=offline.createBufferSource();source.buffer=decoded;source.connect(offline.destination);source.start();const samples=(await offline.startRendering()).getChannelData(0),buffer=new ArrayBuffer(44+samples.length*2),v=new DataView(buffer);const str=(at,s)=>{for(let i=0;iv.setInt16(44+i*2,Math.max(-1,Math.min(1,s))*(s<0?32768:32767),true));return new Blob([buffer],{type:'audio/wav'});} + function bind(setup=false){const root=document.querySelector('#stt-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let busy=false,profiles=[]; + async function refresh(){try{const s=await api('stt');if(!root.isConnected)return;el('stt-status').textContent=setup?`llama.cpp: ${s.installed?'vorhanden':'fehlt'} · Modell: ${s.model?'vorhanden':'fehlt'} · Audio-Projektor: ${s.projector?'vorhanden':'fehlt'}`:s.job?.phase||'Profil und Audiodatei auswählen.';if(setup){el('stt-setup').disabled=busy||(s.projector&&s.model);el('stt-assign').disabled=busy||!s.model||!profiles.length;}else{el('stt-start').disabled=busy||s.job?.state==='running'||!profiles.length;el('stt-cancel').disabled=s.job?.state!=='running';el('stt-result').textContent=s.job?.text||'';}}catch(err){if(root.isConnected)el('stt-error').textContent=err.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}} + if(setup){api('profiles').then(p=>{if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='stt');el('stt-profile').innerHTML=profiles.map(p=>``).join('');}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});el('stt-assign').onclick=async()=>{const p=profiles.find(p=>p.id===el('stt-profile').value);if(!p)return;busy=true;try{await api('stt/assign',{id:p.id,revision:p.revision});el('stt-error').textContent='Profil umgestellt. Jetzt STT → Testen öffnen.';p.revision++;}catch(err){el('stt-error').textContent=err.message;}finally{busy=false;}};el('stt-setup').onclick=async()=>{busy=true;try{await api('stt/setup',{});el('stt-error').textContent='Download ist eingeplant. Fortschritt unter Downloads.';}catch(err){el('stt-error').textContent=err.message;}finally{busy=false;}};} + else{api('profiles').then(p=>{if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='stt'&&p.runnable);el('stt-profile').innerHTML=profiles.map(p=>``).join('')||'';}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});el('stt-form').onsubmit=async ev=>{ev.preventDefault();busy=true;el('stt-start').disabled=true;el('stt-error').textContent='';try{const body=new FormData();body.set('file',await wav(el('stt-file').files[0]),'audio.wav');body.set('profile_id',el('stt-profile').value);body.set('language',el('stt-language').value);if(root.isConnected)await api('stt/start',body);}catch(err){if(root.isConnected)el('stt-error').textContent=err.message;}finally{busy=false;}};el('stt-cancel').onclick=()=>api('stt/cancel',{}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});}refresh(); + } + return {html,bind}; +})(); diff --git a/stt.py b/stt.py new file mode 100644 index 0000000..da80fc8 --- /dev/null +++ b/stt.py @@ -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 0256: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('')[-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) diff --git a/studio.js b/studio.js index 5a92a7d..dd707ca 100644 --- a/studio.js +++ b/studio.js @@ -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():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}

${audioDescriptions[page]&&page!=='audio'?'

Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.

':''}
${[['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])=>``).join('')}
${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'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; + 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():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}

${audioDescriptions[page]&&!['audio','stt'].includes(page)?'

Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.

':''}
${[['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])=>``).join('')}
${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'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; document.querySelector('#view').innerHTML=`
${body}
`; 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 `
EINSTELLUNGEN

Laufzeiten

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.

Text-to-Speech

Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.

TTS einrichten →

llama.cpp

Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →

Docker

Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.

Docker öffnen →
`;} + function runtimeOverview(){return `
EINSTELLUNGEN

Laufzeiten

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.

Spracherkennung

Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.

STT einrichten →

Text-to-Speech

Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.

TTS einrichten →

llama.cpp

Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →

Docker

Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.

Docker öffnen →
`;} function imageRuntime(){return `
EINSTELLUNGEN / LAUFZEITEN

Bildgenerierung

ComfyUI + ComfyUI-GGUF

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.

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.

Installationsstatus wird geprüft …

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} function openProfile(page,modelId){render(page,true,modelId);} return {render,openProfile}; diff --git a/test_endpoint.py b/test_endpoint.py index 9dd0a58..16190db 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -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' diff --git a/test_stt.py b/test_stt.py new file mode 100644 index 0000000..eee2d01 --- /dev/null +++ b/test_stt.py @@ -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()