Add isolated Qwen3 ASR setup test UI and transcription API
This commit is contained in:
+6
-2
@@ -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/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/images/generations` | Qwen-Image-2.1-Rezept, n=1, `b64_json` |
|
||||||
| POST | `/v1/audio/speech` | Qwen3-TTS CustomVoice, WAV-Ausgabe |
|
| 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
|
`model` ist immer der **API-Profilname**, nicht der GGUF-Dateiname. Bildaufträge
|
||||||
verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizite
|
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
|
eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind
|
||||||
deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs.
|
deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs.
|
||||||
Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings,
|
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.
|
eigenen Port, sondern eigene Routen und Worker hinter demselben Listener.
|
||||||
|
|
||||||
Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides
|
Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides
|
||||||
@@ -162,3 +162,7 @@ Profilfreigaben: mehrere Chatprofile, höchstens ein Bildprofil. Die Auswahl ein
|
|||||||
### Sprachausgabe
|
### 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.
|
`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.
|
||||||
|
|||||||
@@ -208,3 +208,7 @@ Betrieb, Authentifizierung und SSH-Tunnel: [ENDPOINT.md](ENDPOINT.md).
|
|||||||
### Optionale TTS-Laufzeit
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und
|
|||||||
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
|
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
|
||||||
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
|
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
|
||||||
konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose.
|
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
|
## Zugang und API-Token
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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
|
### 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.
|
„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.
|
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.
|
||||||
|
|||||||
@@ -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 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.']};
|
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;
|
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==='access'){await accessPage();return;}
|
||||||
if(page==='network'){await networkPage();return;}
|
if(page==='network'){await networkPage();return;}
|
||||||
if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return;
|
if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return;
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ def file_role(filename):
|
|||||||
"""One classification shared by library UI and profile validation."""
|
"""One classification shared by library UI and profile validation."""
|
||||||
name=filename.lower();parts=PurePosixPath(name).parts
|
name=filename.lower();parts=PurePosixPath(name).parts
|
||||||
if re.search(r'(?:^|[/_-])mmproj(?:[/_.-]|$)',name):
|
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:
|
elif 'speech_tokenizer' in parts:
|
||||||
role,label='auxiliary','Sprach-Tokenizer'
|
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):
|
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
@@ -13,8 +13,8 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||||
COPY deploy/tts-requirements.lock /app/deploy/tts-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 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 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-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/
|
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||||
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||||
FILES = ['deploy/tts-requirements.lock','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):
|
def run(*args, check=True, interactive=False):
|
||||||
|
|||||||
+22
-3
@@ -10,6 +10,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||||
from inference import InferenceError
|
from inference import InferenceError
|
||||||
|
from stt import read_upload
|
||||||
|
|
||||||
class APIError(ValueError):
|
class APIError(ValueError):
|
||||||
def __init__(self,message,status=400,code='invalid_request_error'):
|
def __init__(self,message,status=400,code='invalid_request_error'):
|
||||||
@@ -17,7 +18,7 @@ class APIError(ValueError):
|
|||||||
|
|
||||||
class Endpoint:
|
class Endpoint:
|
||||||
def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port):
|
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.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.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)
|
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={}
|
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')]:
|
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt')]:
|
||||||
subset=[p for p in rows if p['kind']==kind]
|
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()
|
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)
|
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):
|
def configure(self,data):
|
||||||
@@ -100,6 +101,7 @@ class Endpoint:
|
|||||||
with self.lock:self.state='stopping';http=self.http
|
with self.lock:self.state='stopping';http=self.http
|
||||||
if http:http.shutdown();http.server_close()
|
if http:http.shutdown();http.server_close()
|
||||||
if self.tts:self.tts.stop()
|
if self.tts:self.tts.stop()
|
||||||
|
if self.stt:self.stt.stop()
|
||||||
self.images.stop();self.worker.stop()
|
self.images.stop();self.worker.stop()
|
||||||
def allowed(self):
|
def allowed(self):
|
||||||
with self.lock:return self.state=='running'
|
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=='/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=='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.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.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.')
|
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
|
||||||
try:length=int(self.headers.get('Content-Length','0'))
|
try:length=int(self.headers.get('Content-Length','0'))
|
||||||
@@ -174,6 +176,23 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||||||
self.close_connection=True
|
self.close_connection=True
|
||||||
if admitted:
|
if admitted:
|
||||||
with ep.lock:ep.inflight-=1
|
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):
|
def speech(self,ep,data):
|
||||||
if not ep.tts:raise APIError('TTS-Laufzeit nicht eingerichtet.',501)
|
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.')
|
if set(data)-{'model','input','voice','response_format','speed','language'}:raise APIError('Nicht unterstützte TTS-Felder.')
|
||||||
|
|||||||
+6
-1
@@ -1,5 +1,6 @@
|
|||||||
"""Read-only, conservative mapping to Deck's maintained execution adapters."""
|
"""Read-only, conservative mapping to Deck's maintained execution adapters."""
|
||||||
from catalog import file_role
|
from catalog import file_role
|
||||||
|
from stt import supported as stt_supported
|
||||||
from profiles import image_recipe
|
from profiles import image_recipe
|
||||||
from tts_runtime import REPO, REVISION
|
from tts_runtime import REPO, REVISION
|
||||||
|
|
||||||
@@ -9,12 +10,16 @@ def assess(model, installed, profile=None):
|
|||||||
if not file_role(filename)['profile_eligible']:
|
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.')
|
result.update(state='component',label='Zusatzdatei',message='Diese Datei wird einem kompatiblen Modellprofil zugeordnet und benötigt keine eigene Laufzeit.')
|
||||||
return result
|
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()])
|
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:
|
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)'])
|
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'):
|
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.')
|
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':
|
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)'))
|
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
|
if not result['runtime']:return result
|
||||||
|
|||||||
+1
-1
@@ -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 & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & 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 & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & 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>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
|
|
||||||
NAME = 'athena-deck-network'
|
NAME = 'athena-deck-network'
|
||||||
BASE = Path('/opt/athena-deck')
|
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):
|
def run(*args, **kwargs):
|
||||||
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ window.ProfilesUI=(()=>{
|
|||||||
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
|
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;
|
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').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-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-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)));
|
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
|
||||||
|
|||||||
+2
-1
@@ -55,7 +55,7 @@ class Profiles:
|
|||||||
def __init__(self,path,catalog):
|
def __init__(self,path,catalog):
|
||||||
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
||||||
self.image_runtime_ready=lambda:False
|
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 []
|
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -74,6 +74,7 @@ class Profiles:
|
|||||||
if p.get('model') and image_recipe(p['model']) and self.image_runtime_ready():
|
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')]
|
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']=='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']=='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.')
|
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'
|
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from http.cookies import SimpleCookie, CookieError
|
|||||||
from auth import CredentialStore, verify_password
|
from auth import CredentialStore, verify_password
|
||||||
from catalog import Catalog
|
from catalog import Catalog
|
||||||
from tts_runtime import TTSRuntime
|
from tts_runtime import TTSRuntime
|
||||||
|
from stt import STT,read_upload
|
||||||
from tts_test import TTSTests
|
from tts_test import TTSTests
|
||||||
from profiles import Profiles
|
from profiles import Profiles
|
||||||
from capacity import assess, overview
|
from capacity import assess, overview
|
||||||
@@ -93,8 +94,11 @@ class Server(ThreadingHTTPServer):
|
|||||||
self.profiles.chat_blockers=self.worker.blockers
|
self.profiles.chat_blockers=self.worker.blockers
|
||||||
self.image_tests.acquire=self.scheduler.image_reservation
|
self.image_tests.acquire=self.scheduler.image_reservation
|
||||||
self.tts_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=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.tts=self.tts_tests
|
||||||
|
self.endpoint.stt=self.stt
|
||||||
if self.endpoint.config['autostart']:
|
if self.endpoint.config['autostart']:
|
||||||
try:self.endpoint.start()
|
try:self.endpoint.start()
|
||||||
except ValueError as exc:self.endpoint.error=str(exc)
|
except ValueError as exc:self.endpoint.error=str(exc)
|
||||||
@@ -241,7 +245,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.headers.get('Host') in allowed
|
return self.headers.get('Host') in allowed
|
||||||
|
|
||||||
def execution_setup(self, model, profile=None):
|
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)
|
return execution_assess(model,installed,profile)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
@@ -255,7 +259,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||||
if not self.authenticated() and self.path == '/':
|
if not self.authenticated() and self.path == '/':
|
||||||
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
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:
|
if self.path in routes:
|
||||||
name, mime = routes[self.path]
|
name, mime = routes[self.path]
|
||||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
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/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/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/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 self.path == '/api/v1/tts':return self.respond(self.server.tts_tests.status())
|
||||||
if urlsplit(self.path).path == '/api/v1/tts/audio':
|
if urlsplit(self.path).path == '/api/v1/tts/audio':
|
||||||
try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
|
try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
|
||||||
@@ -385,6 +390,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.respond(ep.start() if self.path.endswith('/start') else ep.stop())
|
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 ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||||
except OSError:return self.respond({'error':'Endpunkt konnte nicht konfiguriert werden.'},503)
|
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'):
|
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:
|
try:
|
||||||
data=self.read_json();t=self.server.tts_tests
|
data=self.read_json();t=self.server.tts_tests
|
||||||
@@ -421,7 +438,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if self.path == '/api/v1/profiles/delete':
|
if self.path == '/api/v1/profiles/delete':
|
||||||
try:
|
try:
|
||||||
data=self.read_json()
|
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
|
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.")
|
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
|
job=self.server.image_tests.job
|
||||||
@@ -481,6 +500,7 @@ def main():
|
|||||||
server.auto_tests.stop()
|
server.auto_tests.stop()
|
||||||
server.chat_tests.stop()
|
server.chat_tests.stop()
|
||||||
server.endpoint.close()
|
server.endpoint.close()
|
||||||
|
server.stt.stop()
|
||||||
server.tts_tests.stop()
|
server.tts_tests.stop()
|
||||||
server.tts_runtime.stop()
|
server.tts_runtime.stop()
|
||||||
server.image_runtime.stop()
|
server.image_runtime.stop()
|
||||||
|
|||||||
@@ -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 `<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};
|
||||||
|
})();
|
||||||
@@ -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)
|
||||||
@@ -7,14 +7,16 @@ const Studio=(()=>{
|
|||||||
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
||||||
if(category!==page){category=page;section='discover';}
|
if(category!==page){category=page;section='discover';}
|
||||||
if(modelId)section='profiles';
|
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>`;
|
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
||||||
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
|
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==='tts-runtime'){TTSUI.bind(true);return;}
|
||||||
if(page==='runtime'){RuntimeUI.bind();return;}
|
if(page==='runtime'){RuntimeUI.bind();return;}
|
||||||
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
|
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
|
||||||
if(page==='runtimes')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(['test','running'].includes(section)&&page==='audio')TTSUI.bind();
|
||||||
else if(section==='auto')AutoTestUI.bind();
|
else if(section==='auto')AutoTestUI.bind();
|
||||||
else if(section==='test'&&page==='chat')ChatTestUI.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');
|
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);});
|
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 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);}
|
function openProfile(page,modelId){render(page,true,modelId);}
|
||||||
return {render,openProfile};
|
return {render,openProfile};
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ class EndpointTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):self.enable('audio')
|
with self.assertRaises(ValueError):self.enable('audio')
|
||||||
self.assertEqual(self.request('/v1/audio/speech',{})[0],501)
|
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)
|
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):
|
def test_tts_endpoint_returns_wav_and_requires_publication(self):
|
||||||
self.rows[-1].update(runnable=True,blockers=[])
|
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'
|
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
@@ -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()
|
||||||
Reference in New Issue
Block a user