Add isolated Qwen3 TTS runtime, GUI setup and speech endpoint
This commit is contained in:
+6
-2
@@ -46,7 +46,7 @@ Alle Inferenzrouten benötigen `Authorization: Bearer <Deck-API-Token>`.
|
||||
| GET | `/health` | Listener-Health, ebenfalls authentifiziert |
|
||||
| POST | `/v1/chat/completions` | Textchat, JSON und SSE-Streaming, Sampling-Defaults aus dem Profil |
|
||||
| POST | `/v1/images/generations` | Qwen-Image-2.1-Rezept, n=1, `b64_json` |
|
||||
| POST | `/v1/audio/speech` | 501: noch kein eigener TTS-Worker |
|
||||
| POST | `/v1/audio/speech` | Qwen3-TTS CustomVoice, WAV-Ausgabe |
|
||||
| POST | `/v1/audio/transcriptions` | 501: noch kein eigener STT-Worker |
|
||||
|
||||
`model` ist immer der **API-Profilname**, nicht der GGUF-Dateiname. Bildaufträge
|
||||
@@ -55,7 +55,7 @@ verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizi
|
||||
eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind
|
||||
deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs.
|
||||
Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings,
|
||||
Vision-Eingaben, Bildbearbeitung, Video oder Audio-Worker. TTS/STT benötigen keinen
|
||||
Vision-Eingaben, Bildbearbeitung, Video oder STT-/Musik-/Voice-Worker. TTS/STT benötigen keinen
|
||||
eigenen Port, sondern eigene Routen und Worker hinter demselben Listener.
|
||||
|
||||
Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides
|
||||
@@ -158,3 +158,7 @@ oder Zeitlimit als mögliche Ursachen. Bestehende Anwendungslogs werden nicht ge
|
||||
Der interne Chat-Test liefert `job.timings` (Prefill/Generierung: Token, Millisekunden und Token/s) und `job.usage` aus der finalen llama.cpp-Streamantwort. Fehlende Messwerte bleiben leer, insbesondere bei Abbruch. Prefill zählt berechnete Token, usage die gesamte Eingabe inklusive wiederverwendetem Kontext. Modellladen und Warteschlange sind nicht Teil der Token/s.
|
||||
|
||||
Profilfreigaben: mehrere Chatprofile, höchstens ein Bildprofil. Die Auswahl eines Bildprofils entfernt atomar alle anderen Bildfreigaben; laufende Antworten werden dadurch nicht abgebrochen. Der Haken in der Profilkarte zeigt die gespeicherte Freigabe, nicht den Ladezustand. Beim Laden alter Konfigurationen mit mehreren Bildfreigaben bleibt die erste gespeicherte Bild-ID ausgewählt.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -204,3 +204,7 @@ für eine vorhandene Deck-Installation ergänzen. Alle neuen Ports werden vor de
|
||||
Update auf Kollisionen geprüft. Die GUI erlaubt den Wechsel innerhalb des Bereichs
|
||||
bei gestopptem Endpunkt. Nativer Betrieb kennt diese Docker-Beschränkung nicht.
|
||||
Betrieb, Authentifizierung und SSH-Tunnel: [ENDPOINT.md](ENDPOINT.md).
|
||||
|
||||
### Optionale TTS-Laufzeit
|
||||
|
||||
Nach der Grundinstallation wird Qwen3-TTS über Einstellungen → Laufzeiten → Text-to-Speech installiert. Der Installer liefert den Installationscode und die Versionsbindung mit; die große CUDA-Umgebung und Modellgewichte werden erst auf Klick im eigenen Deck-Zustand geladen. Voraussetzung ist ein funktionierender NVIDIA-Treiber mit GPU-Zugriff für Deck. Es werden keine System-Python-Pakete ersetzt. Aktuell wird für die Ausführung eine freie RTX 3060 vorausgesetzt.
|
||||
|
||||
@@ -17,7 +17,7 @@ Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und
|
||||
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
|
||||
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
|
||||
konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose.
|
||||
Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. Audio und Video sind noch nicht angebunden. 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; STT, 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
|
||||
|
||||
@@ -69,7 +69,7 @@ Keine Historie und kein Hintergrund-Collector bei geschlossener Hardware-Seite.
|
||||
- `image_test.py`: eigene ComfyUI-Aufträge, gemeinsam mit dem Router koordiniert.
|
||||
- `endpoint-ui.js`: Übersicht, Port und Profilfreigaben.
|
||||
|
||||
Sprachmodelle und Qwen-Image-Profile sind ausführbar. Audio-/Video-Laufzeiten
|
||||
Sprachmodelle und Qwen-Image-Profile sind ausführbar. Qwen3-TTS CustomVoice ist ebenfalls ausführbar; übrige Audio-/Video-Laufzeiten
|
||||
bleiben vorbereitet; weitere Docker-Dienste werden nur mit Deck-Labels angezeigt.
|
||||
Der native systemd-Installer ist noch nicht vollständig; aktuell gilt der isolierte
|
||||
Docker-Testinstaller. Keine Migration oder Steuerung des bisherigen Routers.
|
||||
|
||||
@@ -222,4 +222,16 @@ Verifikation auf Athena: eigene ungespeicherte Testprofilkopie, 512×512, 4 Schr
|
||||
|
||||
Audio ist in Text-to-Speech (`audio`, bestehende Dateien und Links bleiben erhalten), Spracherkennung (`stt`), Musik (`music`) und Stimme / Voice (`voice`) aufgeteilt. Jede Kategorie hat eigene Suchergebnisse, Bibliothek, Downloads und vorbereitende Profile. Hub-Filter: text-to-speech, automatic-speech-recognition, text-to-audio, audio-to-audio. Musik umfasst gemäß Hub-Task auch Geräuscherzeugung; Voice umfasst auch Audiotrennung. Direkte Repository-Links umgehen wie bisher den Suchfilter.
|
||||
|
||||
Neue Downloads behalten die gewählte Kategorie. Alte Audio-Dateien bleiben bei TTS; es erfolgt keine ungesicherte automatische Umklassifizierung. Noch keine Audio-Laufzeit oder Endpunktfreigabe; die neuen Profile 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; STT, Musik und Voice speichern zunächst nur Modell und Namen. Eigenständige Studio-Oberflächen bleiben Weitere Dienste.
|
||||
|
||||
### Qwen3-TTS einrichten und testen
|
||||
|
||||
Unter **TTS → Einrichten** oder **Einstellungen → Laufzeiten → Text-to-Speech** installiert der Button eine eigene Python-Umgebung, CUDA-PyTorch und das vollständige offizielle Qwen3-TTS-12Hz-1.7B-CustomVoice inklusive Sprach-Tokenizer. Versionen und Modellrevision sind festgelegt (`deploy/tts-requirements.lock`, `tts_runtime.py`). Mindestens 25 GiB freier Datenträgerplatz werden vorausgesetzt. Bestehende Laufzeiten bleiben unverändert. Das Modell erscheint anschließend in der TTS-Bibliothek.
|
||||
|
||||
Ein bestehendes MLX-/Base-Profil kann mit **CUDA-Modell diesem Profil zuordnen** ausdrücklich auf CustomVoice umgestellt werden. Name und Geschwindigkeit bleiben erhalten, die alte Datei wird nicht gelöscht. Alternativ ein neues Profil aus der neuen Bibliotheksdatei erstellen. MLX-Dateien sind für Apple; Base-Referenzstimmen und VoiceDesign sind in dieser ersten Version nicht ausführbar.
|
||||
|
||||
Unter **TTS → Testen** Profil, Stimme und Sprache wählen, Text eingeben und erzeugen. Der Player spielt die WAV-Ausgabe ab; Download und Abbruch sind verfügbar. Die freie RTX 3060 benötigt mindestens 7 GiB VRAM, Deck mindestens 8 GiB RAM-Spielraum. Der Worker läuft offline, nutzt ausschließlich diese GPU und wird nach jedem Auftrag beendet. Die 5080 ist für diesen TTS-Worker noch nicht auswählbar. Geschwindigkeit wird dem Profil entnommen.
|
||||
|
||||
Text wird nur über stdin übertragen und nicht gespeichert. WAV-Dateien bleiben unter `tts-tests/<job-id>/result.wav` im privaten Deck-Zustand; es gibt noch keine automatische Aufräumfunktion. Audio ist nur nach Anmeldung abrufbar. Die API-Freigabe erfolgt wie bei anderen Profilen ausdrücklich über den Endpunkt-Schalter.
|
||||
|
||||
Interne authentifizierte API: `GET /api/v1/tts`, `GET /api/v1/tts/audio?id=…`, `POST /api/v1/tts/install`, `/install-cancel`, `/assign` (`id`, `revision`), `/start` (`profile_id`, `text`, optional `speaker`, `language`) und `/cancel`.
|
||||
|
||||
@@ -8,7 +8,7 @@ const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</stron
|
||||
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
|
||||
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
|
||||
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
|
||||
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
|
||||
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
|
||||
if(page==='access'){await accessPage();return;}
|
||||
if(page==='network'){await networkPage();return;}
|
||||
if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return;
|
||||
|
||||
@@ -41,6 +41,8 @@ def file_role(filename):
|
||||
name=filename.lower();parts=PurePosixPath(name).parts
|
||||
if re.search(r'(?:^|[/_-])mmproj(?:[/_.-]|$)',name):
|
||||
role,label='vision_projector','Vision-Projektor'
|
||||
elif 'speech_tokenizer' in parts:
|
||||
role,label='auxiliary','Sprach-Tokenizer'
|
||||
elif any(p in ('text_encoder','text_encoders') for p in parts) or re.search(r'(?:^|[/_-])(?:text.encoder|qwen3vl|t5xxl|clip_l|clip_g)(?:[/_.-]|$)',name):
|
||||
role,label='text_encoder','Textencoder'
|
||||
elif 'vae' in parts or re.search(r'(?:^|[/_-])vae(?:[/_.-]|$)',name):
|
||||
|
||||
+3
-2
@@ -12,8 +12,9 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
|
||||
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
|
||||
WORKDIR /app
|
||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||
COPY 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 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 deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
|
||||
COPY 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 network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||
FILES = ['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','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):
|
||||
@@ -198,7 +198,7 @@ def update(base, rollback=False, force=False, api_ports=None):
|
||||
backup_name=config['name']+'-previous'
|
||||
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
||||
stamp=str(time.time_ns())
|
||||
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','router-verification-*'))
|
||||
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','router-verification-*'))
|
||||
was_running=previous['State']['Running']
|
||||
if was_running:run('docker','stop','--time','15',config['name'])
|
||||
run('docker','rename',config['name'],backup_name)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
accelerate==1.12.0
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
brotli==1.2.0
|
||||
certifi==2026.7.22
|
||||
cffi==2.1.1
|
||||
charset-normalizer==3.5.1
|
||||
click==8.5.0
|
||||
cloudpickle==3.1.2
|
||||
cuda-bindings==12.9.7
|
||||
cuda-pathfinder==1.6.0
|
||||
cuda-toolkit==12.8.1
|
||||
decorator==5.3.1
|
||||
einops==0.8.2
|
||||
fastapi==0.141.1
|
||||
filelock==3.32.3
|
||||
flatbuffers==25.12.19
|
||||
fsspec==2026.7.0
|
||||
gradio==6.17.3
|
||||
gradio_client==2.5.0
|
||||
groovy==0.1.2
|
||||
h11==0.16.0
|
||||
hf-gradio==0.4.1
|
||||
hf-xet==1.6.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
huggingface_hub==0.36.2
|
||||
idna==3.20
|
||||
Jinja2==3.1.6
|
||||
joblib==1.6.0
|
||||
lazy-loader==0.6
|
||||
librosa==1.0.0
|
||||
llvmlite==0.49.0
|
||||
markdown-it-py==4.2.0
|
||||
MarkupSafe==3.0.3
|
||||
mdurl==0.1.2
|
||||
mpmath==1.3.0
|
||||
msgpack==1.2.2
|
||||
narwhals==2.26.0
|
||||
networkx==3.6.1
|
||||
numba==0.67.0
|
||||
numpy==2.5.3
|
||||
nvidia-cublas-cu12==12.8.4.1
|
||||
nvidia-cuda-cupti-cu12==12.8.90
|
||||
nvidia-cuda-nvrtc-cu12==12.8.93
|
||||
nvidia-cuda-runtime-cu12==12.8.90
|
||||
nvidia-cudnn-cu12==9.19.0.56
|
||||
nvidia-cufft-cu12==11.3.3.83
|
||||
nvidia-cufile-cu12==1.13.1.3
|
||||
nvidia-curand-cu12==10.3.9.90
|
||||
nvidia-cusolver-cu12==11.7.3.90
|
||||
nvidia-cusparse-cu12==12.5.8.93
|
||||
nvidia-cusparselt-cu12==0.7.1
|
||||
nvidia-nccl-cu12==2.28.9
|
||||
nvidia-nvjitlink-cu12==12.8.93
|
||||
nvidia-nvshmem-cu12==3.4.5
|
||||
nvidia-nvtx-cu12==12.8.90
|
||||
onnxruntime==1.30.0
|
||||
orjson==3.12.0
|
||||
packaging==26.3
|
||||
pandas==3.0.6
|
||||
pillow==12.3.0
|
||||
platformdirs==4.12.1
|
||||
pooch==1.9.0
|
||||
protobuf==7.36.2
|
||||
psutil==7.2.2
|
||||
pycparser==3.0
|
||||
pydantic==2.13.5
|
||||
pydantic_core==2.46.5
|
||||
pydub==0.25.1
|
||||
Pygments==2.21.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-multipart==0.0.32
|
||||
pytz==2026.4
|
||||
PyYAML==6.0.3
|
||||
qwen-tts==0.1.1
|
||||
regex==2026.9.10
|
||||
requests==2.34.2
|
||||
rich==15.0.0
|
||||
safehttpx==0.1.7
|
||||
safetensors==0.8.0
|
||||
scikit-learn==1.9.1
|
||||
scipy==1.18.1
|
||||
semantic-version==2.10.0
|
||||
setuptools==78.1.0
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
soundfile==0.14.0
|
||||
sox==1.5.0
|
||||
soxr==1.1.0
|
||||
starlette==1.7.0
|
||||
sympy==1.14.0
|
||||
threadpoolctl==3.7.0
|
||||
tokenizers==0.22.2
|
||||
tomlkit==0.14.0
|
||||
torch==2.11.0+cu128
|
||||
torchaudio==2.11.0+cu128
|
||||
tqdm==4.70.1
|
||||
transformers==4.57.3
|
||||
triton==3.6.0
|
||||
typer==0.27.2
|
||||
typing-inspection==0.4.4
|
||||
typing_extensions==4.16.0
|
||||
urllib3==2.8.0
|
||||
uvicorn==0.54.0
|
||||
+22
-5
@@ -17,7 +17,7 @@ class APIError(ValueError):
|
||||
|
||||
class Endpoint:
|
||||
def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port):
|
||||
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.credentials=credentials
|
||||
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.credentials=credentials
|
||||
self.management_port=management_port;self.lock=threading.RLock();self.http=None;self.thread=None;self.state='stopped';self.error=None;self.inflight=0
|
||||
self.allowed_ports=[int(p) for p in os.environ.get('DECK_API_PORTS','').split(',') if p]
|
||||
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
|
||||
@@ -36,9 +36,9 @@ class Endpoint:
|
||||
return [dict(p,enabled=p['id'] in enabled) for p in rows]
|
||||
def status(self):
|
||||
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
|
||||
for key,kind in [('llm','chat'),('image','image'),('tts','tts'),('stt','stt')]:
|
||||
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt')]:
|
||||
subset=[p for p in rows if p['kind']==kind]
|
||||
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image'))
|
||||
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))
|
||||
scheduler_status=self.scheduler.status()
|
||||
with self.lock:return dict(state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
|
||||
def configure(self,data):
|
||||
@@ -99,6 +99,7 @@ class Endpoint:
|
||||
# Process shutdown does not change the user's autostart preference.
|
||||
with self.lock:self.state='stopping';http=self.http
|
||||
if http:http.shutdown();http.server_close()
|
||||
if self.tts:self.tts.stop()
|
||||
self.images.stop();self.worker.stop()
|
||||
def allowed(self):
|
||||
with self.lock:return self.state=='running'
|
||||
@@ -151,8 +152,8 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
if self.command=='GET' and self.path=='/v1/models':return self.send(ep.model_list())
|
||||
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
|
||||
if self.command!='POST':raise APIError('Route nicht gefunden.',404)
|
||||
if self.path in ('/v1/audio/speech','/v1/audio/transcriptions'):raise APIError('Für TTS/STT ist noch keine Deck-Laufzeit eingerichtet.',501,'not_implemented')
|
||||
if self.path not in ('/v1/chat/completions','/v1/images/generations'):raise APIError('Route nicht implementiert.',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 not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech'):raise APIError('Route nicht implementiert.',404)
|
||||
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
|
||||
try:length=int(self.headers.get('Content-Length','0'))
|
||||
except ValueError:raise APIError('Ungültige Content-Length.') from None
|
||||
@@ -161,6 +162,7 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
except (ValueError,UnicodeError):raise APIError('Ungültiges JSON.') from None
|
||||
if not isinstance(data,dict):raise APIError('JSON-Objekt erforderlich.')
|
||||
if self.path=='/v1/chat/completions':return self.chat(ep,data)
|
||||
if self.path=='/v1/audio/speech':return self.speech(ep,data)
|
||||
return self.image(ep,data)
|
||||
except (APIError,InferenceError) as exc:
|
||||
if isinstance(exc,InferenceError):
|
||||
@@ -172,6 +174,21 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
self.close_connection=True
|
||||
if admitted:
|
||||
with ep.lock:ep.inflight-=1
|
||||
def speech(self,ep,data):
|
||||
if not ep.tts:raise APIError('TTS-Laufzeit nicht eingerichtet.',501)
|
||||
if set(data)-{'model','input','voice','response_format','speed','language'}:raise APIError('Nicht unterstützte TTS-Felder.')
|
||||
if data.get('response_format','wav')!='wav':raise APIError('Derzeit wird nur WAV unterstützt; response_format=wav setzen.')
|
||||
profile=ep.find_profile(data.get('model'),'audio')
|
||||
try:job=ep.tts.start(profile['id'],data.get('input'),speaker=data.get('voice','Ryan'),language=data.get('language','Auto'),speed=data.get('speed'),wait=True)
|
||||
except ValueError as exc:raise APIError(str(exc)) from None
|
||||
end=time.monotonic()+620
|
||||
while time.monotonic()<end:
|
||||
current=ep.tts.status()['job']
|
||||
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503)
|
||||
try:body=ep.tts.audio(job['id'])
|
||||
except (ValueError,OSError):time.sleep(.25);continue
|
||||
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);return
|
||||
raise APIError('TTS-Zeitlimit überschritten.',504)
|
||||
def chat(self,ep,data):
|
||||
supported={'model','messages','stream','stream_options','temperature','top_p','top_k','max_tokens','max_completion_tokens','stop','seed','tools','tool_choice','parallel_tool_calls','response_format','presence_penalty','frequency_penalty','logprobs','top_logprobs','user','n'}
|
||||
if set(data)-supported:raise APIError('Nicht unterstützte Chat-Felder: '+', '.join(sorted(set(data)-supported)))
|
||||
|
||||
+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="/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="#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="/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>
|
||||
|
||||
@@ -12,7 +12,7 @@ import sys
|
||||
|
||||
NAME = 'athena-deck-network'
|
||||
BASE = Path('/opt/athena-deck')
|
||||
FILES = {'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','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
|
||||
|
||||
def run(*args, **kwargs):
|
||||
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ window.ProfilesUI=(()=>{
|
||||
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
|
||||
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
|
||||
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p><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==='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.':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>`);
|
||||
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
|
||||
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
|
||||
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
|
||||
|
||||
+2
-1
@@ -55,7 +55,7 @@ class Profiles:
|
||||
def __init__(self,path,catalog):
|
||||
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
||||
self.image_runtime_ready=lambda:False
|
||||
self.chat_blockers=None
|
||||
self.chat_blockers=None;self.tts_blockers=None
|
||||
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
||||
def status(self):
|
||||
with self.lock:
|
||||
@@ -73,6 +73,7 @@ class Profiles:
|
||||
except ValueError as exc:p['model']=None;p['blockers']=[str(exc)]
|
||||
if p.get('model') and image_recipe(p['model']) and self.image_runtime_ready():
|
||||
p['blockers']=[b for b in p['blockers'] if not b.startswith('Eine eigene Bildlaufzeit')]
|
||||
if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p)
|
||||
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p)
|
||||
if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.')
|
||||
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
|
||||
|
||||
@@ -7,6 +7,8 @@ import secrets
|
||||
from http.cookies import SimpleCookie, CookieError
|
||||
from auth import CredentialStore, verify_password
|
||||
from catalog import Catalog
|
||||
from tts_runtime import TTSRuntime
|
||||
from tts_test import TTSTests
|
||||
from profiles import Profiles
|
||||
from capacity import assess, overview
|
||||
from runtime import Runtime
|
||||
@@ -70,6 +72,9 @@ class Server(ThreadingHTTPServer):
|
||||
self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime")
|
||||
self.image_tests.runtime = self.image_runtime
|
||||
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
|
||||
self.tts_runtime=TTSRuntime(self.catalog.root.parent/'tts-runtime')
|
||||
self.tts_tests=TTSTests(self.catalog.root.parent/'tts-tests',self.profiles,self.tts_runtime)
|
||||
self.profiles.tts_blockers=self.tts_tests.blockers
|
||||
self.docker = DockerSupport()
|
||||
self.hardware = HardwareProvider()
|
||||
self.started = time.time()
|
||||
@@ -86,7 +91,9 @@ class Server(ThreadingHTTPServer):
|
||||
self.scheduler=Scheduler(self.worker)
|
||||
self.profiles.chat_blockers=self.worker.blockers
|
||||
self.image_tests.acquire=self.scheduler.image_reservation
|
||||
self.tts_tests.acquire=self.scheduler.image_reservation
|
||||
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
|
||||
if self.endpoint.config['autostart']:
|
||||
try:self.endpoint.start()
|
||||
except ValueError as exc:self.endpoint.error=str(exc)
|
||||
@@ -243,7 +250,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||
if not self.authenticated() and self.path == '/':
|
||||
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
||||
routes = {'/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 = {'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
|
||||
if self.path in routes:
|
||||
name, mime = routes[self.path]
|
||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
||||
@@ -255,6 +262,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.path == '/api/v1/chat-tests':return self.respond(self.server.chat_tests.status())
|
||||
if self.path == '/api/v1/endpoint':return self.respond(self.server.endpoint.status())
|
||||
if self.path == '/api/v1/docker':return self.respond(self.server.docker.status())
|
||||
if self.path == '/api/v1/tts':return self.respond(self.server.tts_tests.status())
|
||||
if urlsplit(self.path).path == '/api/v1/tts/audio':
|
||||
try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
|
||||
except (OSError,ValueError):return self.respond({'error':'Audio nicht verfügbar.'},404)
|
||||
if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status())
|
||||
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
|
||||
if urlsplit(self.path).path == '/api/v1/image-tests/image':
|
||||
@@ -364,6 +375,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.respond(ep.start() if self.path.endswith('/start') else ep.stop())
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
except OSError:return self.respond({'error':'Endpunkt konnte nicht konfiguriert werden.'},503)
|
||||
if self.path in ('/api/v1/tts/install','/api/v1/tts/install-cancel','/api/v1/tts/assign','/api/v1/tts/start','/api/v1/tts/cancel'):
|
||||
try:
|
||||
data=self.read_json();t=self.server.tts_tests
|
||||
if self.path.endswith('/install') and not data:return self.respond(self.server.tts_runtime.start())
|
||||
if self.path.endswith('/install-cancel') and not data:return self.respond(self.server.tts_runtime.stop())
|
||||
if self.path.endswith('/assign') and set(data)=={'id','revision'}:return self.respond(t.assign(data['id'],data['revision']))
|
||||
if self.path.endswith('/start') and set(data)=={'profile_id','text','speaker','language'}:return self.respond(t.start(**data))
|
||||
if self.path.endswith('/cancel') and not data:return self.respond(t.stop())
|
||||
raise ValueError('Ungültige TTS-Anfrage.')
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
except Exception:return self.respond({'error':'TTS-Aktion fehlgeschlagen.'},503)
|
||||
if self.path in ('/api/v1/image-runtime/install','/api/v1/image-runtime/cancel'):
|
||||
try:
|
||||
if self.read_json():raise ValueError('Keine Parameter erwartet.')
|
||||
@@ -389,7 +411,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.path == '/api/v1/profiles/delete':
|
||||
try:
|
||||
data=self.read_json()
|
||||
with self.server.image_tests.lock:
|
||||
with self.server.image_tests.lock, self.server.tts_tests.lock:
|
||||
tts_job=self.server.tts_tests.job
|
||||
if tts_job and tts_job.get("state")=="running" and tts_job.get("profile_id")==data.get("id"):raise ValueError("Dieses TTS-Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.")
|
||||
job=self.server.image_tests.job
|
||||
if job and job.get('state')=='running' and job.get('profile_id')==data.get('id'):raise ValueError('Dieses Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.')
|
||||
return self.respond(self.server.profiles.delete(data))
|
||||
@@ -447,6 +471,8 @@ def main():
|
||||
server.auto_tests.stop()
|
||||
server.chat_tests.stop()
|
||||
server.endpoint.close()
|
||||
server.tts_tests.stop()
|
||||
server.tts_runtime.stop()
|
||||
server.image_runtime.stop()
|
||||
server.image_tests.stop()
|
||||
server.runtime.stop()
|
||||
|
||||
@@ -7,13 +7,16 @@ const Studio=(()=>{
|
||||
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
||||
if(category!==page){category=page;section='discover';}
|
||||
if(modelId)section='profiles';
|
||||
const body=['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]?'<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'],...(['image','chat'].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==='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==='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>`;
|
||||
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
||||
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
|
||||
if(page==='tts-runtime'){TTSUI.bind(true);return;}
|
||||
if(page==='runtime'){RuntimeUI.bind();return;}
|
||||
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
|
||||
if(page==='runtimes')return;
|
||||
if(section==='auto')AutoTestUI.bind();
|
||||
if(section==='setup')TTSUI.bind(true);
|
||||
else if(['test','running'].includes(section)&&page==='audio')TTSUI.bind();
|
||||
else if(section==='auto')AutoTestUI.bind();
|
||||
else if(section==='test'&&page==='chat')ChatTestUI.bind();
|
||||
else if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running');
|
||||
else if(section==='running'&&page==='chat')EndpointUI.bindRunning();
|
||||
@@ -21,7 +24,7 @@ const Studio=(()=>{
|
||||
else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads');
|
||||
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
|
||||
}
|
||||
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><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 führen Modelle aus. 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 imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF und das unterstützte FLUX.2-Klein-9B-Modell. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.</p><p>Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Bei großen FLUX-Gewichten wird zusätzlich System-RAM genutzt. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
|
||||
function openProfile(page,modelId){render(page,true,modelId);}
|
||||
return {render,openProfile};
|
||||
|
||||
@@ -55,6 +55,15 @@ class EndpointTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):self.enable('audio')
|
||||
self.assertEqual(self.request('/v1/audio/speech',{})[0],501)
|
||||
self.record['api_token_hash']=hashlib.sha256(b'B'*32).hexdigest();self.assertEqual(self.request()[0],401);self.assertEqual(self.request(token='B'*32)[0],200)
|
||||
def test_tts_endpoint_returns_wav_and_requires_publication(self):
|
||||
self.rows[-1].update(runnable=True,blockers=[])
|
||||
self.ep.tts=Mock();self.ep.tts.start.return_value={'id':'a'*32};self.ep.tts.status.return_value={'job':{'id':'a'*32,'state':'complete'}};self.ep.tts.audio.return_value=b'RIFF-test-WAVE'
|
||||
self.assertEqual(self.request('/v1/audio/speech',{'model':'audio','input':'hello'})[0],404)
|
||||
self.enable('audio')
|
||||
self.assertEqual(self.request('/v1/audio/speech',{'model':'audio','input':'hello','response_format':'mp3'})[0],400)
|
||||
c=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);c.request('POST','/v1/audio/speech',json.dumps({'model':'audio','input':'hello','voice':'Ryan','response_format':'wav'}),{'Content-Type':'application/json','Authorization':'Bearer '+'A'*32});r=c.getresponse()
|
||||
self.assertEqual(r.status,200);self.assertEqual(r.getheader('Content-Type'),'audio/wav');self.assertEqual(r.read(),b'RIFF-test-WAVE');c.close()
|
||||
self.assertEqual(self.ep.tts.start.call_args.kwargs['speaker'],'Ryan')
|
||||
def test_single_image_selection_preserves_multiple_llms(self):
|
||||
self.rows.append(dict(self.rows[2],id='image2',name='image2'))
|
||||
self.enable('alpha');self.enable('beta');self.enable('image');self.enable('image2')
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import tempfile,unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from tts_test import TTSTests
|
||||
from tts_runtime import TTSRuntime,REPO,REVISION
|
||||
class TTSTestsValidation(unittest.TestCase):
|
||||
def test_validation_and_reservation_release(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
t=TTSTests(d,SimpleNamespace(status=lambda:{'profiles':[]}),TTSRuntime(Path(d)/'runtime'));release=Mock();t.acquire=Mock(return_value=release)
|
||||
for kwargs in ({'text':''},{'text':'ok','speaker':'unknown'},{'text':'ok','language':'invalid'},{'text':'ok','speed':9}):
|
||||
with self.assertRaises(ValueError):t.start('p',**kwargs)
|
||||
t.acquire.assert_not_called()
|
||||
with self.assertRaises(ValueError):t.start('p','valid')
|
||||
release.assert_called_once()
|
||||
def test_audio_path_and_model_format(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
r=TTSRuntime(Path(d)/'runtime');t=TTSTests(d,None,r)
|
||||
for ident in ('../secret','a'*32):
|
||||
with self.assertRaises(ValueError):t.audio(ident)
|
||||
self.assertTrue(t.blockers({'model':{'repo':'mlx-community/example','file':'model.safetensors'}}))
|
||||
self.assertTrue(t.blockers({'model':{'repo':REPO,'revision':REVISION,'file':'speech_tokenizer/model.safetensors'}}))
|
||||
self.assertFalse(r.status()['installed'])
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,17 @@
|
||||
window.TTSUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(path='',data){const r=await fetch('/api/v1/'+path,(data===undefined)?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const x=await r.json();if(!r.ok)throw Error(x.error||'TTS-Anfrage fehlgeschlagen');return x;}
|
||||
function html(setup=false){return `<section id="tts-panel" class="card"><h2>${setup?'Qwen3-TTS einrichten':'Sprache ausprobieren'}</h2><p id="tts-status" role="status"></p><p id="tts-error" role="alert"></p>${setup?'<p>Die Einrichtung installiert eine eigene CUDA-Laufzeit und das offizielle Qwen3-TTS 1.7B CustomVoice mit eingebauten Stimmen. Dafür werden mehrere GB heruntergeladen. MLX-Dateien sind für Apple-Geräte; vorhandene Dateien werden nicht gelöscht.</p><button id="tts-install">TTS-Laufzeit und Modell einrichten</button><button class="secondary" id="tts-install-cancel">Einrichtung abbrechen</button><h3>Vorhandenes TTS-Profil verbinden</h3><p>Ersetzt im ausgewählten Profil die Modellzuordnung durch die eingerichtete CUDA-Variante. Der Profilname bleibt erhalten.</p><select id="tts-profile"></select><button id="tts-assign">CUDA-Modell diesem Profil zuordnen</button>':'<form id="tts-form"><label>Profil<select id="tts-profile" required></select></label><div class="form-grid"><label>Stimme<select id="tts-speaker"></select></label><label>Sprache<select id="tts-language"></select></label></div><label>Text<textarea id="tts-text" rows="5" maxlength="1000" required placeholder="Was soll vorgelesen werden?"></textarea></label><button id="tts-generate">Sprache erzeugen</button><button type="button" id="tts-cancel" class="secondary">Abbrechen</button></form><div id="tts-result"></div><p>Ausführung auf der RTX 3060. Andere Deck-Modellaufträge werden koordiniert; fremde Dienste werden nicht gestoppt. Text wird nicht dauerhaft gespeichert. Ergebnisdateien bleiben auf Athena.</p>'}</section>`;}
|
||||
function bind(setup=false){const root=document.querySelector('#tts-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let state,profiles=[],busy=false,lastResult='';const error=x=>{if(root.isConnected)el('tts-error').textContent=x;};
|
||||
async function refresh(){try{state=await api('tts');if(!root.isConnected)return;const rt=state.runtime,j=state.job;el('tts-status').textContent=setup?(rt.job?.phase||(rt.installed?'TTS ist eingerichtet.':'Noch nicht eingerichtet.')):(j?.phase||'Profil wählen und Text eingeben.');
|
||||
if(setup){el('tts-install').disabled=busy||rt.installed||rt.job?.state==='running';el('tts-install-cancel').disabled=rt.job?.state!=='running';el('tts-assign').disabled=busy||!rt.installed||!profiles.length;}
|
||||
else{el('tts-generate').disabled=busy||j?.state==='running'||!profiles.length||!rt.installed;el('tts-cancel').disabled=j?.state!=='running';if(j?.state==='complete'&&lastResult!==j.id){lastResult=j.id;el('tts-result').innerHTML=`<h3>Ergebnis</h3><audio controls src="/api/v1/tts/audio?id=${e(j.id)}"></audio><p><a class="link" download="athena-sprache.wav" href="/api/v1/tts/audio?id=${e(j.id)}">WAV herunterladen</a></p>`;}}
|
||||
}catch(err){error(err.message);}finally{if(root.isConnected)setTimeout(refresh,2000);}}
|
||||
async function load(){const p=await api('profiles');if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='audio'&&(setup||p.runnable));el('tts-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst ein TTS-Profil anlegen und unter Einrichten verbinden</option>';}
|
||||
async function action(path,data={}){busy=true;error('');try{await api('tts/'+path,data);if(path==='assign'){await load();error('Profil verbunden. Jetzt den Reiter Testen öffnen.');}}catch(err){error(err.message);}finally{busy=false;}}
|
||||
if(setup){el('tts-install').onclick=()=>action('install');el('tts-install-cancel').onclick=()=>action('install-cancel');el('tts-assign').onclick=()=>{const p=profiles.find(p=>p.id===el('tts-profile').value);if(p)action('assign',{id:p.id,revision:p.revision});};}
|
||||
else{api('tts').then(s=>{if(!root.isConnected)return;el('tts-speaker').innerHTML=s.runtime.speakers.map(v=>`<option ${v==='Ryan'?'selected':''}>${e(v)}</option>`).join('');el('tts-language').innerHTML=s.runtime.languages.map(v=>`<option ${v==='German'?'selected':''}>${e(v)}</option>`).join('');}).catch(x=>error(x.message));el('tts-form').onsubmit=ev=>{ev.preventDefault();action('start',{profile_id:el('tts-profile').value,text:el('tts-text').value,speaker:el('tts-speaker').value,language:el('tts-language').value});};el('tts-cancel').onclick=()=>action('cancel');}
|
||||
load().catch(x=>error(x.message));refresh();
|
||||
}
|
||||
return {html,bind};
|
||||
})();
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Own CUDA environment and pinned official CustomVoice model, installable via GUI."""
|
||||
import json,sys,time,os,uuid,shutil,threading,hashlib
|
||||
from pathlib import Path
|
||||
from image_runtime import ImageRuntime
|
||||
REPO='Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice'
|
||||
REVISION='0c0e3051f131929182e2c023b9537f8b1c68adfe'
|
||||
SPEAKERS=['Vivian','Serena','Uncle_Fu','Dylan','Eric','Ryan','Aiden','Ono_Anna','Sohee']
|
||||
LANGUAGES=['Auto','German','English','Chinese','Japanese','Korean','French','Russian','Portuguese','Spanish','Italian']
|
||||
class TTSRuntime(ImageRuntime):
|
||||
def paths(self):
|
||||
marker=self.root/'active.json'
|
||||
if marker.exists():
|
||||
ident=json.loads(marker.read_text()).get('id','')
|
||||
if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):return self.root/ident/'python/bin/python',self.root/ident/'model'
|
||||
return self.root/'missing/python',self.root/'missing/model'
|
||||
def status(self):
|
||||
with self.lock:
|
||||
python,model=self.paths()
|
||||
return dict(installed=python.is_file() and (model/'ready').exists(),job=dict(self.job) if self.job else None,repo=REPO,revision=REVISION,speakers=SPEAKERS,languages=LANGUAGES)
|
||||
def ensure_library(self):
|
||||
if not self.status()['installed']:raise ValueError('Zuerst TTS einrichten.')
|
||||
_,model=self.paths();ident=hashlib.sha256((REPO+REVISION+'model.safetensors').encode()).hexdigest();target=self.root.parent/'models'/ident;target.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
if (target/'entry.json').is_file() and (target/'model.safetensors').is_file() and json.loads((target/'entry.json').read_text()).get('sha256'):return ident
|
||||
dest=target/'model.safetensors'
|
||||
if not dest.exists():dest.symlink_to(model/'model.safetensors')
|
||||
digest=hashlib.sha256()
|
||||
with (model/'model.safetensors').open('rb') as source:
|
||||
for block in iter(lambda:source.read(1024*1024),b''):digest.update(block)
|
||||
entry=dict(repo=REPO,revision=REVISION,file='model.safetensors',size=(model/'model.safetensors').stat().st_size,kind='audio',downloaded_at=time.time(),sha256=digest.hexdigest(),upstream_hash_verified=False,state='downloaded',runtime_ready=True)
|
||||
tmp=target/'entry.tmp';tmp.write_text(json.dumps(entry));tmp.replace(target/'entry.json');return ident
|
||||
def start(self):
|
||||
with self.lock:
|
||||
if self.job and self.job['state']=='running':raise ValueError('Installation läuft bereits.')
|
||||
if self.status()['installed']:self.ensure_library();return self.status()
|
||||
if sys.platform!='linux':raise ValueError('Die TTS-Laufzeit benötigt Debian/Linux mit NVIDIA-CUDA.')
|
||||
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
if shutil.disk_usage(self.root).free<25*1024**3:raise ValueError('Mindestens 25 GiB freier Speicher erforderlich.')
|
||||
self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='TTS-Einrichtung vorbereitet',started_at=time.time());self._save()
|
||||
self.worker=threading.Thread(target=self._run,daemon=True);self.worker.start();return self.status()
|
||||
def _run(self):
|
||||
base=self.root/self.job['id'];python=base/'python/bin/python';model=base/'model';state='failed';phase='Einrichtung fehlgeschlagen'
|
||||
try:
|
||||
base.mkdir(mode=0o700)
|
||||
self._phase('Eigene TTS-Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python'])
|
||||
self._phase('CUDA-PyTorch installieren');self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchaudio==2.11.0','--index-url','https://download.pytorch.org/whl/cu128'])
|
||||
self._phase('Qwen-TTS 0.1.1 installieren');self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/tts-requirements.lock','qwen-tts==0.1.1'])
|
||||
self._command([python,'-m','pip','check'])
|
||||
self._phase('Offizielles CUDA-Modell und Sprach-Tokenizer laden · rund 4,5 GB')
|
||||
code='from huggingface_hub import snapshot_download; snapshot_download(repo_id='+repr(REPO)+',revision='+repr(REVISION)+',local_dir='+repr(str(model))+',allow_patterns=["*.json","*.txt","*.safetensors"],max_workers=2)'
|
||||
self._command([python,'-c',code])
|
||||
self._phase('Dateien und Laufzeit prüfen');self._command([python,'-c','from qwen_tts import Qwen3TTSModel; import torch,soundfile; assert torch.version.cuda'])
|
||||
for name in ['model.safetensors','config.json','merges.txt','vocab.json','tokenizer_config.json','speech_tokenizer/model.safetensors']:
|
||||
if not (model/name).is_file():raise RuntimeError('Modelldatei fehlt: '+name)
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
(model/'ready').write_text(REVISION)
|
||||
marker=self.root/'active.tmp';marker.write_text(json.dumps(dict(id=base.name)));marker.replace(self.root/'active.json')
|
||||
self.ensure_library()
|
||||
state='complete';phase='TTS bereit · CUDA-Modell mit eingebauten Stimmen installiert'
|
||||
except InterruptedError:state='cancelled';phase='TTS-Einrichtung abgebrochen'
|
||||
except Exception as exc:phase=str(exc) if isinstance(exc,RuntimeError) else 'TTS-Einrichtung fehlgeschlagen. Speicher und Internetverbindung prüfen.'
|
||||
finally:
|
||||
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
"""Serial own TTS workers with shared model reservation, WAV results and cancellation."""
|
||||
import json,os,signal,subprocess,threading,time,uuid,hashlib
|
||||
from pathlib import Path
|
||||
from tts_runtime import REPO,REVISION,SPEAKERS,LANGUAGES
|
||||
from image_test import probe,cgroup_headroom
|
||||
class TTSTests:
|
||||
def __init__(self,root,profiles,runtime):
|
||||
self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.lock=threading.RLock();self.job=None;self.process=None;self.cancel=threading.Event();self.acquire=lambda wait=False:lambda:None
|
||||
def status(self):
|
||||
with self.lock:return dict(job=dict(self.job) if self.job else None,runtime=self.runtime.status())
|
||||
def blockers(self,p):
|
||||
if p['model']['repo']!=REPO or p['model']['file']!='model.safetensors' or p['model'].get('revision')!=REVISION:return ['Dieses Modell ist nicht CUDA-CustomVoice. Unter TTS → Einrichten die passende Variante installieren und dem Profil zuordnen.']
|
||||
return [] if self.runtime.status()['installed'] else ['TTS-Laufzeit unter TTS → Einrichten installieren.']
|
||||
def assign(self,ident,revision):
|
||||
if not self.runtime.status()['installed']:raise ValueError('Zuerst TTS einrichten.')
|
||||
entry_id=self.runtime.ensure_library()
|
||||
p=next((p for p in self.profiles.status()['profiles'] if p['id']==ident),None)
|
||||
if not p or p['kind']!='audio':raise ValueError('TTS-Profil nicht gefunden.')
|
||||
return self.profiles.save(dict(id=ident,revision=revision,name=p['name'],kind='audio',model_id=entry_id,parameters=p['parameters']))
|
||||
def start(self,profile_id,text,speaker='Ryan',language='German',wait=False,speed=None):
|
||||
if not isinstance(text,str) or not 1<=len(text.strip())<=1000:raise ValueError('Text mit 1–1000 Zeichen erforderlich.')
|
||||
if speaker not in SPEAKERS or language not in LANGUAGES:raise ValueError('Ungültige Stimme oder Sprache.')
|
||||
if speed is not None and (type(speed) not in (int,float) or not .25<=speed<=4):raise ValueError('Geschwindigkeit muss zwischen 0,25 und 4 liegen.')
|
||||
release=self.acquire(wait)
|
||||
try:
|
||||
with self.lock:
|
||||
if self.job and self.job['state']=='running':raise ValueError('TTS-Auftrag läuft bereits.')
|
||||
p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='audio'),None)
|
||||
if not p or not p['runnable']:raise ValueError('TTS-Profil noch nicht eingerichtet.')
|
||||
gpu=next((g for g in probe() if 'RTX 3060' in g['name'] and not g['processes'] and g['free_mib']>7000),None)
|
||||
if not gpu:raise ValueError('Die RTX 3060 muss frei sein und mindestens 7 GiB freien Speicher haben.')
|
||||
headroom=cgroup_headroom()
|
||||
if headroom is not None and headroom<8*1024**3:raise ValueError('Zu wenig freier Deck-RAM für TTS.')
|
||||
self.cancel.clear();ident=uuid.uuid4().hex;self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
self.job=dict(id=ident,state='running',phase='Sprachmodell wird geladen',profile_id=profile_id,started_at=time.time())
|
||||
threading.Thread(target=self._run,args=(ident,text,speaker,language,speed if speed is not None else p['parameters']['speed'],gpu,release),daemon=True).start();return dict(self.job)
|
||||
except Exception:release();raise
|
||||
def _run(self,ident,text,speaker,language,speed,gpu,release):
|
||||
directory=self.root/ident;process=None;state='failed';phase='TTS-Ausführung fehlgeschlagen'
|
||||
try:
|
||||
directory.mkdir(mode=0o700);python,model=self.runtime.paths()
|
||||
env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','LLAMA_'))};env.update(CUDA_VISIBLE_DEVICES=gpu['uuid'],HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='2',HOME=str(directory))
|
||||
with self.lock:
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
process=subprocess.Popen([str(python),str(Path(__file__).with_name('tts_worker.py'))],stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True,env=env);self.process=process;self.job['phase']='Sprache wird auf der RTX 3060 erzeugt'
|
||||
process.stdin.write(json.dumps(dict(model=str(model),output=str(directory),text=text,speaker=speaker,language=language,speed=speed)).encode());process.stdin.close()
|
||||
end=time.monotonic()+600
|
||||
while process.poll() is None:
|
||||
if self.cancel.wait(1):raise InterruptedError()
|
||||
if time.monotonic()>end:raise ValueError('Zeitlimit der Sprachgenerierung erreicht.')
|
||||
current=next((g for g in probe() if g['uuid']==gpu['uuid']),None)
|
||||
if not current or current['processes']>1:raise ValueError('Die reservierte GPU wurde anderweitig belegt.')
|
||||
result=json.loads((directory/'result.json').read_text()) if (directory/'result.json').exists() else dict(ok=False,error='Worker beendet: möglicher Speichermangel oder Laufzeitfehler.')
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
if not result['ok']:raise ValueError(result['error'])
|
||||
wav=directory/'result.wav'
|
||||
if not wav.exists() or wav.stat().st_size>32*1024**2:raise ValueError('Ungültige Audioausgabe.')
|
||||
state='complete';phase='Sprache fertig · Modell entladen'
|
||||
except InterruptedError:state='cancelled';phase='Sprachgenerierung abgebrochen'
|
||||
except Exception as exc:phase=str(exc) if isinstance(exc,ValueError) else 'Sprachgenerierung fehlgeschlagen. Laufzeit und Ressourcen prüfen.'
|
||||
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
|
||||
if state=='complete':(directory/'complete').touch()
|
||||
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time())
|
||||
release()
|
||||
def stop(self):
|
||||
self.cancel.set()
|
||||
with self.lock:process=self.process
|
||||
if process and process.poll() is None:
|
||||
try:os.killpg(process.pid,signal.SIGTERM)
|
||||
except ProcessLookupError:pass
|
||||
return dict(cancellation_requested=True)
|
||||
def audio(self,ident):
|
||||
if not isinstance(ident,str) or len(ident)!=32 or any(c not in '0123456789abcdef' for c in ident) or not (self.root/ident/'complete').exists():raise ValueError('Audio noch nicht verfügbar.')
|
||||
return (self.root/ident/'result.wav').read_bytes()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""One isolated offline Qwen-TTS request; stdin is never persisted."""
|
||||
import json,sys
|
||||
from pathlib import Path
|
||||
|
||||
def main():
|
||||
request=json.load(sys.stdin);root=Path(request['output'])
|
||||
try:
|
||||
import torch,soundfile as sf
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
model=Qwen3TTSModel.from_pretrained(request['model'],device_map='cuda:0',dtype=torch.bfloat16,attn_implementation='sdpa',local_files_only=True)
|
||||
waves,rate=model.generate_custom_voice(text=request['text'],language=request['language'],speaker=request['speaker'],max_new_tokens=1024)
|
||||
wave=waves[0]
|
||||
if request['speed']!=1:
|
||||
import librosa
|
||||
wave=librosa.effects.time_stretch(wave,rate=request['speed'])
|
||||
sf.write(root/'result.wav',wave,rate,subtype='PCM_16')
|
||||
(root/'result.json').write_text(json.dumps(dict(ok=True,sample_rate=rate,duration=len(wave)/rate)))
|
||||
except Exception as exc:
|
||||
(root/'result.json').write_text(json.dumps(dict(ok=False,error='CUDA-Speicher reicht nicht aus.' if 'outofmemory' in type(exc).__name__.lower() or 'out of memory' in str(exc).lower() else 'TTS-Ausführung fehlgeschlagen: '+type(exc).__name__)))
|
||||
sys.exit(1)
|
||||
if __name__=='__main__':main()
|
||||
Reference in New Issue
Block a user