diff --git a/ENDPOINT.md b/ENDPOINT.md index 7294d05..fe92676 100644 --- a/ENDPOINT.md +++ b/ENDPOINT.md @@ -46,7 +46,7 @@ Alle Inferenzrouten benötigen `Authorization: Bearer `. | 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. diff --git a/INSTALL.md b/INSTALL.md index 2981d2a..43f6d48 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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. diff --git a/README.md b/README.md index a7b71e3..0f0c8df 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose. -Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. 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. diff --git a/STUDIO.md b/STUDIO.md index c456c5d..1edba16 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -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//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`. diff --git a/app.js b/app.js index 00a7e17..4217e70 100644 --- a/app.js +++ b/app.js @@ -8,7 +8,7 @@ const metric=(title,value)=>`
${title}${value}v==null?'':``; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; -if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','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; diff --git a/catalog.py b/catalog.py index cbc9aad..657eff4 100644 --- a/catalog.py +++ b/catalog.py @@ -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): diff --git a/deploy/Dockerfile b/deploy/Dockerfile index ae1414b..26efe79 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 \ diff --git a/deploy/install.py b/deploy/install.py index 58be804..a9dc26a 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['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) diff --git a/deploy/tts-requirements.lock b/deploy/tts-requirements.lock new file mode 100644 index 0000000..446c349 --- /dev/null +++ b/deploy/tts-requirements.lock @@ -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 diff --git a/endpoint.py b/endpoint.py index b4ec816..7d4e002 100644 --- a/endpoint.py +++ b/endpoint.py @@ -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()Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
+Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
diff --git a/network/install_remote.py b/network/install_remote.py index 2b45bbb..4ae758d 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -12,7 +12,7 @@ import sys NAME = 'athena-deck-network' BASE = Path('/opt/athena-deck') -FILES = {'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) diff --git a/profiles-ui.js b/profiles-ui.js index cea5861..f25b8e9 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -8,7 +8,7 @@ window.ProfilesUI=(()=>{ const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length; el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

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

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}

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

${e(t)}

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

Noch keine Profile

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

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

${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.

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

${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':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.

`); root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}}); root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}}); root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components))); diff --git a/profiles.py b/profiles.py index 29ebeaa..0aed3b8 100644 --- a/profiles.py +++ b/profiles.py @@ -55,7 +55,7 @@ class Profiles: def __init__(self,path,catalog): self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() self.image_runtime_ready=lambda:False - self.chat_blockers=None + self.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' diff --git a/server.py b/server.py index 7f00541..95e665b 100644 --- a/server.py +++ b/server.py @@ -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() diff --git a/studio.js b/studio.js index 202df2c..503dff3 100644 --- a/studio.js +++ b/studio.js @@ -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():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

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

${audioDescriptions[page]?'

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

':''}
${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['image','chat'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>``).join('')}
${section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'

Keine von Deck gestarteten Modelle

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

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; + const body=page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

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

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

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

':''}
${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='audio'?[['setup','Einrichten']]:[]),...(['image','chat','audio'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>``).join('')}
${section==='setup'?TTSUI.html(true):['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'

Keine von Deck gestarteten Modelle

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

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

Laufzeiten

Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

llama.cpp

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

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →

Docker

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

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

Laufzeiten

Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

Text-to-Speech

Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.

TTS einrichten →

llama.cpp

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

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →

Docker

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

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

Bildgenerierung

ComfyUI + ComfyUI-GGUF

Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF und das unterstützte FLUX.2-Klein-9B-Modell. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.

Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Bei großen FLUX-Gewichten wird zusätzlich System-RAM genutzt. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.

Installationsstatus wird geprüft …

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

${setup?'Qwen3-TTS einrichten':'Sprache ausprobieren'}

${setup?'

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.

Vorhandenes TTS-Profil verbinden

Ersetzt im ausgewählten Profil die Modellzuordnung durch die eingerichtete CUDA-Variante. Der Profilname bleibt erhalten.

':'

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.

'}
`;} + 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=`

Ergebnis

WAV herunterladen

`;}} + }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=>``).join('')||'';} + 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=>``).join('');el('tts-language').innerHTML=s.runtime.languages.map(v=>``).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}; +})(); diff --git a/tts_runtime.py b/tts_runtime.py new file mode 100644 index 0000000..a2c771a --- /dev/null +++ b/tts_runtime.py @@ -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() diff --git a/tts_test.py b/tts_test.py new file mode 100644 index 0000000..3a7f2c3 --- /dev/null +++ b/tts_test.py @@ -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() diff --git a/tts_worker.py b/tts_worker.py new file mode 100644 index 0000000..088f223 --- /dev/null +++ b/tts_worker.py @@ -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()