Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2052adb78 | ||
|
|
5c34afa7fa | ||
|
|
af25425aee | ||
|
|
72c9d8c485 | ||
|
|
f8b1b19d4a | ||
|
|
1904f2104f | ||
|
|
6bd6c48a95 | ||
|
|
fe2a93eeb1 | ||
|
|
7c95dab324 | ||
|
|
436dee6f1e | ||
|
|
1d5a113158 | ||
|
|
43321b6797 | ||
|
|
795101b746 | ||
|
|
2a278bd5bd | ||
|
|
bc2ca9af7d | ||
|
|
e5e5d7fa4d | ||
|
|
08ff3d7c4e | ||
|
|
52482627be | ||
|
|
d3534627f0 | ||
|
|
0e45518e6e | ||
|
|
4cdd837482 | ||
|
|
13a6714b2b | ||
|
|
51ed201f6c | ||
|
|
b4f4bf37fd | ||
|
|
87a2ae5704 | ||
|
|
17f1a08d7d | ||
|
|
68d02f32bd | ||
|
|
535bd751b5 | ||
|
|
805228abfd | ||
|
|
0fd1966ae3 | ||
|
|
c5bbebecc6 | ||
|
|
2342495d24 | ||
|
|
0f0e77928a | ||
|
|
e2f35517f8 | ||
|
|
30203bf13b | ||
|
|
0069b61dbb | ||
|
|
a4e894fe70 | ||
|
|
f58d61140e | ||
|
|
56c382f71f | ||
|
|
eeebbd06eb | ||
|
|
5e18b7776b | ||
|
|
2724861224 | ||
|
|
f1ed51a302 | ||
|
|
30fdbd4b7a | ||
|
|
3e3fbbe9bd | ||
|
|
edb845eb19 | ||
|
|
f7ff14a1ce | ||
|
|
5746ac0e2c | ||
|
|
e82e0340e4 | ||
|
|
f82dc081c9 | ||
|
|
3220a67f1b | ||
|
|
636e48ce93 | ||
|
|
118e32005e | ||
|
|
2ae61baec7 | ||
|
|
1844551534 | ||
|
|
61aa20cb52 | ||
|
|
5f3d064bb4 | ||
|
|
cbc312257c | ||
|
|
63e7a93ee4 | ||
|
|
cc416150a8 | ||
|
|
44e1c1c50e | ||
|
|
f553108912 | ||
|
|
744a207e5a | ||
|
|
8dac735680 | ||
|
|
be8a654f1e | ||
|
|
5afdf46a7c | ||
|
|
435c59da41 | ||
|
|
384d81f6cd | ||
|
|
023c2ee40d | ||
|
|
65ce9642cc | ||
|
|
df41175960 | ||
|
|
0a68df22eb | ||
|
|
42ec28c9f6 | ||
|
|
f1fdca3efd | ||
|
|
6d9f31dff0 | ||
|
|
b0ecc83462 | ||
|
|
fb0cb40bed | ||
|
|
4153e535d3 | ||
|
|
b3e86cc7ae | ||
|
|
5f793020b0 | ||
|
|
12392ccdd7 | ||
|
|
16ac177782 | ||
|
|
460a9f0207 | ||
|
|
e30f4f024c | ||
|
|
7e14da77f9 | ||
|
|
c031fd2c55 | ||
|
|
91fbb276bd | ||
|
|
548f6643fd | ||
|
|
78096c9027 | ||
|
|
ee28272999 | ||
|
|
b2ea53c383 | ||
|
|
82a1809423 | ||
|
|
3fbcc6ee2c | ||
|
|
21d10db890 | ||
|
|
211ede9fc5 | ||
|
|
7e36f1dbb7 | ||
|
|
a15bb2a0cf | ||
|
|
8be1cd6a6f | ||
|
|
014583e7f6 | ||
|
|
5b78d12112 | ||
|
|
27d2bb1b4b | ||
|
|
9c10b0ab88 | ||
|
|
c27636ac34 | ||
|
|
40e73d82a8 | ||
|
|
d71f2ebbfe | ||
|
|
0b927d47b9 | ||
|
|
9bc7d9803a | ||
|
|
aab9579b06 | ||
|
|
da3571ef9f |
No files matched your search
+14
-18
@@ -3,15 +3,15 @@ AI_BIND_ADDRESS=10.77.0.2
|
|||||||
MODEL_DIR=/data/models
|
MODEL_DIR=/data/models
|
||||||
ROUTER_API_KEY=GENERATED_BY_INSTALLER
|
ROUTER_API_KEY=GENERATED_BY_INSTALLER
|
||||||
CONTROLLER_TOKEN=GENERATED_BY_INSTALLER
|
CONTROLLER_TOKEN=GENERATED_BY_INSTALLER
|
||||||
FLUX_MODEL_DIR=/data/models/FLUX.2-klein-4B
|
FLUX_COMPONENT_DIR=/data/models/FLUX.2-klein-9B-components
|
||||||
IMAGE_GPU_DEVICES=1
|
FLUX_TRANSFORMER_DIR=/data/models/FLUX.2-klein-9B-fp8
|
||||||
PIPER_TTS_VERSION=1.6.0
|
IMAGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
|
||||||
PIPER_VOICE=de_DE-thorsten-high
|
QWEN3_TTS_IMAGE=ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98
|
||||||
XTTS_IMAGE=ghcr.io/coqui-ai/xtts-streaming-server:latest-cuda121@sha256:f7fb3b1f9d4bc88af94da1b5959d8002f1e0b003c97557164034eb8a29f01b90
|
QWEN3_TTS_CACHE_DIR=/data/models/qwen3-tts-cache
|
||||||
XTTS_CACHE_DIR=/data/models/xtts-v2-cache
|
QWEN3_TTS_VOICES_DIR=/data/models/qwen3-tts-voices
|
||||||
XTTS_GPU_DEVICE=GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
QWEN3_TTS_GPU_DEVICE=GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
AI_DNS=192.168.1.1
|
AI_DNS=192.168.1.1
|
||||||
DEFAULT_REASONING_EFFORT=medium
|
DEFAULT_REASONING_EFFORT=off
|
||||||
|
|
||||||
FAST_MODEL_FILE=qwen-mix/Qwen3.8-27B-IQ4-MIX.gguf
|
FAST_MODEL_FILE=qwen-mix/Qwen3.8-27B-IQ4-MIX.gguf
|
||||||
MEDIUM_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
MEDIUM_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
@@ -19,7 +19,6 @@ LARGE_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
|||||||
ULTRA_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
ULTRA_MODEL_FILE=qwen-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
UNCENSORED_MODEL_FILE=qwen3.8-27b-abliterated/Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf
|
UNCENSORED_MODEL_FILE=qwen3.8-27b-abliterated/Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf
|
||||||
UNCENSORED_PROJECTOR_FILE=qwen3.8-27b-abliterated/mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf
|
UNCENSORED_PROJECTOR_FILE=qwen3.8-27b-abliterated/mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf
|
||||||
EXPERIMENTAL_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
|
||||||
VISION_PROJECTOR_FILE=qwen/mmproj-BF16.gguf
|
VISION_PROJECTOR_FILE=qwen/mmproj-BF16.gguf
|
||||||
|
|
||||||
FAST_CONTEXT=76800
|
FAST_CONTEXT=76800
|
||||||
@@ -37,24 +36,21 @@ ULTRA_UBATCH_SIZE=128
|
|||||||
UNCENSORED_CONTEXT=80000
|
UNCENSORED_CONTEXT=80000
|
||||||
UNCENSORED_BATCH_SIZE=2048
|
UNCENSORED_BATCH_SIZE=2048
|
||||||
UNCENSORED_UBATCH_SIZE=128
|
UNCENSORED_UBATCH_SIZE=128
|
||||||
EXPERIMENTAL_CONTEXT=76800
|
FAST_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
FAST_GPU_DEVICES=0,1
|
MEDIUM_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
MEDIUM_GPU_DEVICES=0,1
|
|
||||||
MEDIUM_TENSOR_SPLIT=85,15
|
MEDIUM_TENSOR_SPLIT=85,15
|
||||||
LARGE_GPU_DEVICES=0,1
|
LARGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
LARGE_TENSOR_SPLIT=86,14
|
LARGE_TENSOR_SPLIT=86,14
|
||||||
ULTRA_GPU_DEVICES=0,1
|
ULTRA_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
ULTRA_TENSOR_SPLIT=80,20
|
ULTRA_TENSOR_SPLIT=80,20
|
||||||
UNCENSORED_GPU_DEVICES=0,1
|
UNCENSORED_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe,GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
UNCENSORED_TENSOR_SPLIT=90,10
|
UNCENSORED_TENSOR_SPLIT=90,10
|
||||||
UNCENSORED_MTP_MAX=2
|
UNCENSORED_MTP_MAX=2
|
||||||
EXPERIMENTAL_GPU_DEVICES=0
|
|
||||||
LLAMA_THREADS=6
|
LLAMA_THREADS=6
|
||||||
LLAMA_THREADS_BATCH=6
|
LLAMA_THREADS_BATCH=6
|
||||||
FAST_PARALLEL_SLOTS=1
|
FAST_PARALLEL_SLOTS=1
|
||||||
LLAMA_CACHE_RAM_MIB=24576
|
LLAMA_CACHE_RAM_MIB=32768
|
||||||
MEDIUM_PARALLEL_SLOTS=1
|
MEDIUM_PARALLEL_SLOTS=1
|
||||||
LARGE_PARALLEL_SLOTS=1
|
LARGE_PARALLEL_SLOTS=1
|
||||||
ULTRA_PARALLEL_SLOTS=1
|
ULTRA_PARALLEL_SLOTS=1
|
||||||
UNCENSORED_PARALLEL_SLOTS=1
|
UNCENSORED_PARALLEL_SLOTS=1
|
||||||
EXPERIMENTAL_PARALLEL_SLOTS=1
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Repository-Regeln für Modelltests
|
||||||
|
|
||||||
|
- Vor jedem Download, Benchmark oder neuen Profil zuerst
|
||||||
|
`docs/TESTED_MODELS.md` vollständig prüfen.
|
||||||
|
- Ein bereits verworfenes oder ersetztes Artefakt nicht erneut testen, sofern
|
||||||
|
sich nicht mindestens Runtime, Hardware, Quantisierung oder Modellrevision
|
||||||
|
konkret geändert hat. Den neuen Grund im Testbericht festhalten.
|
||||||
|
- Nach jedem Modelltest `docs/TESTED_MODELS.md` im selben Commit aktualisieren:
|
||||||
|
Datum, exaktes Repository, exakter Dateiname beziehungsweise Ollama-Tag,
|
||||||
|
Quantisierung, Kontext, Ergebnis, Entscheidung und Belegpfad.
|
||||||
|
- Ein heruntergeladenes, aber nicht belastbar getestetes Modell als
|
||||||
|
`unvollständig` eintragen; nicht stillschweigend als verworfen behandeln.
|
||||||
|
- Verworfene Gewichte erst löschen, nachdem die entscheidenden Resultate
|
||||||
|
dauerhaft dokumentiert sind.
|
||||||
|
|
||||||
@@ -10,9 +10,13 @@ Sie betreibt:
|
|||||||
|
|
||||||
- llama.cpp mit genau einem aktiven Qwen-Profil,
|
- llama.cpp mit genau einem aktiven Qwen-Profil,
|
||||||
- den OpenAI-kompatiblen Profile Router,
|
- den OpenAI-kompatiblen Profile Router,
|
||||||
- FLUX.2-klein-4B für Textbilder und Referenzbild-Bearbeitung,
|
- FLUX.2 Klein 9B FP8 Beta für Textbilder und Referenzbild-Bearbeitung,
|
||||||
- XTTS und Piper für Sprache,
|
- Qwen3-TTS für Sprache,
|
||||||
|
- ACE-Step 1.5 XL-SFT als exklusiven Musikstudio-Modus,
|
||||||
|
- YuE2-3B mit Ladypoly-WebUI als zweiten, getrennten Musikstudio-Modus,
|
||||||
|
- TRELLIS.2 4B Q8 als exklusives Bild-zu-3D-Studio,
|
||||||
- das Athena-Dashboard,
|
- das Athena-Dashboard,
|
||||||
|
- Portainer CE als optionale Ansicht auf die laufenden Docker-Container,
|
||||||
- WireGuard-Gateway und Datenbackup,
|
- WireGuard-Gateway und Datenbackup,
|
||||||
- den hostgebundenen Athena-Operator.
|
- den hostgebundenen Athena-Operator.
|
||||||
|
|
||||||
@@ -27,8 +31,21 @@ werden keine zweiten Instanzen dieser Dienste angelegt.
|
|||||||
| `/data/models` | Modellgewichte |
|
| `/data/models` | Modellgewichte |
|
||||||
| `/data/llama-dashboard` | historische Dashboard-Messwerte |
|
| `/data/llama-dashboard` | historische Dashboard-Messwerte |
|
||||||
| `/data/docker-backups` | automatische Athena-Backups |
|
| `/data/docker-backups` | automatische Athena-Backups |
|
||||||
|
| `/data/trellis-studio` | trellis.cpp-Runtime und erzeugte 3D-Modelle |
|
||||||
|
| `/data/models/yue2`, `/data/music/yue2` | YuE2-Gewichte und dauerhafte Ergebnisse |
|
||||||
| `/etc/mike-ai` | lokale Konfiguration und Secrets, niemals Git |
|
| `/etc/mike-ai` | lokale Konfiguration und Secrets, niemals Git |
|
||||||
|
|
||||||
|
Portainer läuft als separater, optionaler Verwaltungscontainer
|
||||||
|
`mike-ai-portainer` im internen Frontend-Netz und ist ausschließlich über den
|
||||||
|
namensbasierten Proxy des WireGuard-Gateways unter
|
||||||
|
`https://192.168.1.212:9443` erreichbar. Das Dashboard verwendet denselben
|
||||||
|
stabilen Aufbau auf Port 8099. Beide teilen ausdrücklich nicht den
|
||||||
|
Netzwerk-Namespace des Gateway-Containers: Ein Recreate des Gateways kann sie
|
||||||
|
dadurch nicht mehr in einem veralteten Namespace zurücklassen. Portainers
|
||||||
|
Einstellungen liegen im Docker-Volume `portainer_data`, das vom Athena-Backup
|
||||||
|
mitgesichert wird. Portainer beobachtet beziehungsweise
|
||||||
|
verwaltet Docker, ist aber keine Abhängigkeit des Inferenz-Stacks.
|
||||||
|
|
||||||
## Standardbefehle
|
## Standardbefehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -48,13 +65,37 @@ Qwen-Profil wird vom Profile Controller verwaltet.
|
|||||||
- Fast: kurze, interaktive Aufgaben
|
- Fast: kurze, interaktive Aufgaben
|
||||||
- Medium/Large/Ultra: steigende Kontextgrößen desselben lokalen Qwen-Modells
|
- Medium/Large/Ultra: steigende Kontextgrößen desselben lokalen Qwen-Modells
|
||||||
- Uncensored: separates lokales Profil
|
- Uncensored: separates lokales Profil
|
||||||
- FLUX.2-klein-4B: Bildgenerierung und Editing; Qwen wird dafür kurz entladen und danach
|
- FLUX.2 Klein 9B FP8 Beta: Der Transformer läuft auf der RTX 5080, der
|
||||||
automatisch wiederhergestellt
|
Qwen3-8B-NF4-Textencoder vorübergehend auf der RTX 3060. Das aktive
|
||||||
- XTTS: RTX 3060; Piper bleibt CPU-Fallback
|
llama.cpp-Profil und Qwen3-TTS werden dafür gestoppt und danach automatisch
|
||||||
|
wiederhergestellt. Die Beta arbeitet mit 1024 × 1024 Pixeln, vier Schritten
|
||||||
|
und Guidance 1,0.
|
||||||
|
- Qwen3-TTS 1.7B: RTX 3060. Der Router reicht
|
||||||
|
zusätzlich natives 24-kHz-PCM für den optionalen Hermes-Streaming-Adapter
|
||||||
|
unter `integrations/hermes-qwen3-stream` durch.
|
||||||
|
- ACE-Step 1.5 XL-SFT: exklusiver Musikmodus auf der RTX 5080. Dashboard und
|
||||||
|
die Routerbefehle `/athena music`, `/athena llm`, `/athena status` bedienen
|
||||||
|
dieselbe persistente Zustandsmaschine; siehe `docs/OPERATING_MODES.md`.
|
||||||
|
- YuE2-3B: eigener exklusiver Musikmodus mit Score- und Remix-Funktionen unter
|
||||||
|
`http://192.168.1.212:8014`. Der Routerbefehl lautet `/athena yue2`.
|
||||||
|
- TRELLIS.2 4B Q8: exklusives Bild-zu-3D-Profil auf der RTX 5080. Die
|
||||||
|
browserbasierte Oberfläche läuft unter `http://192.168.1.212:8013`, erzeugt
|
||||||
|
GLB und verwendet standardmäßig `1024 · cascade`. Der 1536er Pfad kann die
|
||||||
|
16 GiB VRAM überschreiten.
|
||||||
|
|
||||||
Die verbindlichen Werte stehen in `config/profile-matrix.json` und
|
Die verbindlichen Werte stehen in `config/profile-matrix.json` und
|
||||||
`docs/STANDARD_PROFILE_MATRIX.md`.
|
`docs/STANDARD_PROFILE_MATRIX.md`.
|
||||||
|
|
||||||
|
## Globale Modellrichtlinie
|
||||||
|
|
||||||
|
`config/global-system-policy.txt` wird vom Profile Router allen Textanfragen
|
||||||
|
über `/v1/chat/completions` und `/v1/responses` vorangestellt. Sie gilt damit
|
||||||
|
für alle Hermes-Profile und andere Clients, die den Athena-Router verwenden.
|
||||||
|
Der Router liest die Datei bei jeder Anfrage neu; spätere Textänderungen
|
||||||
|
benötigen nach der erstmaligen Bereitstellung keinen Container-Neustart.
|
||||||
|
Clients außerhalb des Routers und Werkzeuge, die ein Frontend nicht anbietet,
|
||||||
|
werden dadurch nicht erfasst.
|
||||||
|
|
||||||
## Werkzeuge
|
## Werkzeuge
|
||||||
|
|
||||||
Portable Werkzeuge gehören auf Unraid in eigene, per DockerMan verwaltete
|
Portable Werkzeuge gehören auf Unraid in eigene, per DockerMan verwaltete
|
||||||
@@ -69,6 +110,9 @@ Bootloader, Partitionen, Mounts, SSH, LAN, WireGuard oder Firewall ändern.
|
|||||||
Secrets dürfen lokal verwendet, aber nie in Git, Logs oder Chatantworten
|
Secrets dürfen lokal verwendet, aber nie in Git, Logs oder Chatantworten
|
||||||
veröffentlicht werden.
|
veröffentlicht werden.
|
||||||
|
|
||||||
|
Vor Änderungen durch einen Agenten ist [for_ki.md](for_ki.md) vollständig zu
|
||||||
|
lesen. Dort stehen insbesondere Modus-, Label-, Netzwerk- und Aufräumregeln.
|
||||||
|
|
||||||
## Fertig bedeutet
|
## Fertig bedeutet
|
||||||
|
|
||||||
- Änderung ist im kanonischen Git-Checkout,
|
- Änderung ist im kanonischen Git-Checkout,
|
||||||
|
|||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
ATHENA – AUFBAU VON UNTEN NACH OBEN
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Stand: 10.09.2026 nach Entfernung von Beta 1 und Piper sowie Integration von
|
||||||
|
TRELLIS.2 als 3D-Studio.
|
||||||
|
Athena besitzt derzeit 23 Container, fünf auswählbare LLM-Profile und vier
|
||||||
|
verwendete Docker-Volumes. Verwaiste Docker-Volumes gibt es nicht.
|
||||||
|
|
||||||
|
|
||||||
|
+--------------------------------------+
|
||||||
|
| PHYSISCHER RECHNER: ATHENA |
|
||||||
|
| |
|
||||||
|
| CPU, RAM, Systemplatte, Netzwerk |
|
||||||
|
| NVIDIA RTX 5080 + NVIDIA RTX 3060 |
|
||||||
|
+------------------+-------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+--------------------------------------+
|
||||||
|
| DEBIAN-HOSTSYSTEM |
|
||||||
|
| |
|
||||||
|
| - startet den Rechner |
|
||||||
|
| - verwaltet Netzwerk und Datenträger |
|
||||||
|
| - stellt NVIDIA-Treiber bereit |
|
||||||
|
| - führt Docker aus |
|
||||||
|
+------------------+-------------------+
|
||||||
|
|
|
||||||
|
+-------------------+-------------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+----------------------------------+ +----------------------------------+
|
||||||
|
| DOCKER-STACK | | DAUERHAFTE DATEN AUF DEM HOST |
|
||||||
|
| | | |
|
||||||
|
| - Router und Profilsteuerung | | - Modelle und Modellgewichte |
|
||||||
|
| - llama.cpp-Modellserver | | - Trainingsdatensätze |
|
||||||
|
| - Athena-Dashboard | | - trainierte Stimmen |
|
||||||
|
| - Bild-, Musik- und Audiodienste | | - Checkpoints und Ergebnisse |
|
||||||
|
| - Applio und Mikes Applio UI | | - Konfigurationen und Logs |
|
||||||
|
| - Hilfs- und Netzwerkdienste | | |
|
||||||
|
+----------------+-----------------+ | Hauptpfade: |
|
||||||
|
| | /data |
|
||||||
|
| liest und schreibt | /etc/mike-ai |
|
||||||
|
+-------------------->| |
|
||||||
|
+----------------+-----------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------------------+
|
||||||
|
| BACKUP |
|
||||||
|
| |
|
||||||
|
| Sichert ausgewählte dauerhafte |
|
||||||
|
| Daten und Konfigurationen. |
|
||||||
|
+----------------------------------+
|
||||||
|
|
||||||
|
|
||||||
|
DOCKER-STACK: CONTAINER-INVENTAR
|
||||||
|
================================
|
||||||
|
|
||||||
|
Bestandsaufnahme vom 10.09.2026. "Gestoppt/bereit" bedeutet hier nicht
|
||||||
|
automatisch defekt: GPU-intensive Dienste werden absichtlich nur im passenden
|
||||||
|
Betriebsmodus gestartet. Zum Zeitpunkt der Aufnahme war Applio/RVC aktiv.
|
||||||
|
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| Container | Zustand | Aufgabe |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-router | läuft | Zentrale API; leitet Text-, Bild-, Audio- |
|
||||||
|
| | | und Profilanfragen an den passenden Dienst. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-profile-controller | läuft | Schaltet Profile und Betriebsmodi und sorgt |
|
||||||
|
| | | dafür, dass sich GPU-Dienste nicht stören. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-fast | gestoppt/bereit | llama.cpp-Textmodell mit kleinem Kontext und |
|
||||||
|
| | | hoher Geschwindigkeit. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-medium | gestoppt/bereit | llama.cpp-Textmodell mit mittlerem Kontext. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-large | gestoppt/bereit | llama.cpp-Textmodell mit großem Kontext. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-ultra | gestoppt/bereit | llama.cpp-Textmodell mit maximalem Kontext. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-uncensored | gestoppt/bereit | Separates ungefiltertes llama.cpp-Profil. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-image-worker | gestoppt/bereit | Lokale Bildgenerierung und Bildbearbeitung; |
|
||||||
|
| | | wird nur für Bildaufträge geladen. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-qwen3-tts | gestoppt/bereit | Hochwertige GPU-Sprachausgabe mit Qwen3-TTS. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-tts-gateway | läuft | Normalisiert Text, wandelt Audioformate und |
|
||||||
|
| | | streamt die Ausgabe von Qwen3-TTS. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-whisper | läuft | Lokale Spracherkennung: Sprache zu Text. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-music-acestep-test | gestoppt/bereit | ACE-Step 1.5: erzeugt und bearbeitet Musik. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-music-ui | läuft | Community-Weboberfläche für ACE-Step; das |
|
||||||
|
| | | eigentliche Musikmodell wird separat geladen.|
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-yue2-playground | gestoppt/bereit | Eigenständiges YuE2-Musikstudio mit Score-, |
|
||||||
|
| | | Generierungs- und Remix-Funktionen auf :8014.|
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-stem-separator | gestoppt/bereit | Trennt Gesang, Begleitung und Instrumente |
|
||||||
|
| | | mit BS-RoFormer und Demucs. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-trellis-studio | läuft/bedarfsgest.| TRELLIS.2 4B Q8 erzeugt aus einem Bild ein |
|
||||||
|
| | | texturiertes GLB-Modell auf der RTX 5080. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-voice-studio | gestoppt/bereit | Voice Studio für Text-zu-Stimme und |
|
||||||
|
| | | referenzbasierte Stimmerzeugung. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-xvc-studio | gestoppt/bereit | X-VC für direkte Stimme-zu-Stimme-Umwandlung |
|
||||||
|
| | | ohne vorheriges RVC-Training. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-applio-studio | läuft | Applio/RVC-Backend: Training, Modelle, |
|
||||||
|
| | | Sprachumwandlung und Original-Weboberfläche. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-mikes-applio-ui | läuft | Eigene geführte Oberfläche für das Applio- |
|
||||||
|
| | | Backend; enthält selbst kein RVC-Modell. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-llama-dashboard | läuft | Athena-Dashboard: Zustand, Telemetrie und |
|
||||||
|
| | | Umschaltung der Betriebsmodi. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-portainer | läuft | Allgemeine Webverwaltung und Einsicht für |
|
||||||
|
| | | Docker-Container, Images, Netze und Volumes. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-wireguard-gateway | läuft | Stellt die Athena-Webdienste ausschließlich |
|
||||||
|
| | | über den privaten WireGuard-Zugang bereit. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-mcp-athena-operator | läuft | Kontrollierte Verwaltungswerkzeuge für |
|
||||||
|
| | | Athena, unter anderem für Hermes. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
| mike-ai-backup | läuft | Sichert regelmäßig die dauerhaften Daten und |
|
||||||
|
| | | Konfigurationen von Athena. |
|
||||||
|
+-----------------------------------+-------------------+----------------------------------------------+
|
||||||
|
|
||||||
|
Die Container gehören technisch zu mehreren Compose-Projekten, werden hier
|
||||||
|
aber gemeinsam als Athena-Docker-Stack betrachtet:
|
||||||
|
|
||||||
|
- Kernsystem: /opt/mike-ai/stack
|
||||||
|
- Applio/RVC: /opt/mike-ai/stack/experiments/applio-rvc
|
||||||
|
- Mikes Applio UI: /opt/mike-ai/Mikes-Applio-UI
|
||||||
|
- ACE-Step-Musik: /opt/mike-ai/acestep-test
|
||||||
|
- Spurentrennung: /opt/mike-ai/stem-separator
|
||||||
|
- Voice Studio: /opt/mike-ai/omnivoice-studio
|
||||||
|
- X-VC: /opt/mike-ai/xvc-studio
|
||||||
|
- 3D Studio: /opt/mike-ai/trellis-studio
|
||||||
|
|
||||||
|
|
||||||
|
LLM-PROFILE
|
||||||
|
===========
|
||||||
|
|
||||||
|
Es läuft immer höchstens eines dieser Profile. Fast, Medium, Large und
|
||||||
|
Uncensored können zusätzlich den Vision-Projektor verwenden. Ultra reserviert
|
||||||
|
den verfügbaren Speicher für den maximalen Textkontext und läuft ohne Vision.
|
||||||
|
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Profil | API-Modell | Kontext | Zweck |
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Fast | qwen-fast | 76.800 Token | Hohe Geschwindigkeit und |
|
||||||
|
| | | | kurze bis mittlere Aufgaben.|
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Medium | qwen-medium | 160.000 Token | Ausgewogenes Standardprofil.|
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Large | qwen-large | 192.000 Token | Umfangreiche Dokumente und |
|
||||||
|
| | | | lange technische Arbeiten. |
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Ultra | qwen-ultra | 262.144 Token | Maximaler Textkontext; ohne |
|
||||||
|
| | | | Vision-Projektor. |
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
| Uncensored | qwen-uncensored | 80.000 Token | Weniger restriktives |
|
||||||
|
| | | | Spezialprofil. |
|
||||||
|
+------------+-------------------+----------------+-----------------------------+
|
||||||
|
|
||||||
|
Die produktiven Standardprofile verwenden Qwen3.8-27B in Q4-Quantisierung.
|
||||||
|
Das frühere Beta-1-Profil mit GSQ-RCO IQ3_S wurde entfernt: Es benötigte zwar
|
||||||
|
weniger Speicher, war im gemessenen Betrieb aber überwiegend langsamer und
|
||||||
|
brachte keinen belastbaren Qualitäts- oder Geschwindigkeitsvorteil.
|
||||||
|
|
||||||
|
|
||||||
|
BETRIEBSMODI UND GPU-UMSCHALTUNG
|
||||||
|
===============================
|
||||||
|
|
||||||
|
Die großen GPU-Dienste laufen gegenseitig exklusiv. Der Router speichert den
|
||||||
|
gewählten Zustand und die Profilsteuerung entlädt vor einem Wechsel die nicht
|
||||||
|
benötigten Modelle.
|
||||||
|
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Modus | Geladener Hauptdienst |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| LLM | Ein Qwen-LLM-Profil und Qwen3-TTS. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Musik | ACE-Step 1.5 für Musikgenerierung. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| YuE2 Studio | YuE2-3B für Musik, Score-Steuerung und Audio-Remix. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Audio trennen | BS-RoFormer, Demucs oder MossFormer2. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Voice Studio | OmniVoice für referenzbasierte Text-zu-Sprache-Ausgabe. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| X-VC | Direkte Stimme-zu-Stimme-Umwandlung. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| Applio / RVC | RVC-Inferenz, Modellverwaltung und Stimmtraining. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
| 3D Studio | TRELLIS.2 4B Q8 über trellis.cpp auf der RTX 5080. |
|
||||||
|
+---------------+------------------------------------------------------------+
|
||||||
|
|
||||||
|
Qwen3-TTS läuft nur im LLM-Modus. In einem exklusiven Spezialmodus bleibt das
|
||||||
|
leichte TTS-Gateway als API-Dienst gesund, meldet aber "ready: false", weil das
|
||||||
|
eigentliche Qwen3-TTS-Modell absichtlich entladen ist.
|
||||||
|
|
||||||
|
Das 3D-Studio ist im privaten WireGuard-Netz unter
|
||||||
|
http://192.168.1.212:8013 erreichbar. Es erzeugt GLB-Dateien; empfohlen ist
|
||||||
|
1024 · cascade. Runtime und Ausgaben liegen unter /data/trellis-studio, die
|
||||||
|
Q8-Gewichte unter /data/models/trellis2-q8.
|
||||||
|
|
||||||
|
|
||||||
|
TTS-AUFBAU
|
||||||
|
===========
|
||||||
|
|
||||||
|
+-----------------------------+
|
||||||
|
| Router / OpenAI-TTS-Endpunkt|
|
||||||
|
+--------------+--------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-----------------------------+
|
||||||
|
| mike-ai-tts-gateway |
|
||||||
|
| - Text normalisieren |
|
||||||
|
| - Ausgabeformat umwandeln |
|
||||||
|
| - PCM-Streaming |
|
||||||
|
+--------------+--------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-----------------------------+
|
||||||
|
| mike-ai-qwen3-tts |
|
||||||
|
| Qwen3-TTS 1.7B / Serena |
|
||||||
|
+-----------------------------+
|
||||||
|
|
||||||
|
Piper und sein CPU-Fallback wurden vollständig entfernt. Das TTS-Gateway
|
||||||
|
bleibt notwendig, weil es die stabile Schnittstelle und die Verarbeitung um
|
||||||
|
Qwen3-TTS herum bereitstellt. Wenn Qwen3-TTS nicht geladen ist, steht keine
|
||||||
|
Sprachausgabe zur Verfügung; es wird nicht mehr auf ein zweites Modell
|
||||||
|
zurückgegriffen.
|
||||||
|
|
||||||
|
|
||||||
|
DAUERHAFTE DOCKER-VOLUMES
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Bestandsprüfung vom 10.09.2026: Alle vier Volumes sind einem vorhandenen
|
||||||
|
Container zugeordnet. "docker volume ls -f dangling=true" liefert keine
|
||||||
|
Treffer.
|
||||||
|
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| Volume | Verwendung |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| mike-ai_router-state | Persistenter Routerzustand und Betriebsmodus. |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| mike-ai_router-images | Vom Router und Bilddienst erzeugte Bilder. |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| mike-ai_whisper-data | Lokales Whisper-Modell für Sprache-zu-Text. |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
| portainer_data | Einstellungen und Daten von Portainer. |
|
||||||
|
+-------------------------+-----------------------------------------------+
|
||||||
|
|
||||||
|
Das frühere Volume "mike-ai_piper-data" wurde zusammen mit Piper gelöscht.
|
||||||
|
Beta 1 besaß kein eigenes Docker-Volume; seine rund 12 GB Modellgewichte lagen
|
||||||
|
als Hostverzeichnis unter /data/models und wurden ebenfalls gelöscht.
|
||||||
|
|
||||||
|
Viele Fachdienste verwenden statt Docker-Volumes direkte Hostverzeichnisse.
|
||||||
|
Die wichtigsten davon sind:
|
||||||
|
|
||||||
|
- /data/models Modellgewichte und Modell-Caches
|
||||||
|
- /data/voice/applio Applio-Datensätze, Logs und Stimmenmodelle
|
||||||
|
- /data/music Musikprojekte und generierte Titel
|
||||||
|
- /data/audio/separation Ergebnisse der Audio- und Spurentrennung
|
||||||
|
- /data/trellis-studio trellis.cpp-Runtime und erzeugte GLB-Dateien
|
||||||
|
- /data/llama-dashboard Verlauf und Zustandsdaten des Dashboards
|
||||||
|
- /etc/mike-ai betriebliche Konfiguration und Geheimnisse
|
||||||
|
- /data/docker-backups erzeugte Sicherungsarchive
|
||||||
|
|
||||||
|
Diese Verzeichnisse sind keine Docker-Volumes. Ein leerer Docker-Volume-Check
|
||||||
|
beweist deshalb nicht automatisch, dass unter /data keine alten Experiment-
|
||||||
|
oder Modelldateien mehr liegen.
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP UND DISASTER RECOVERY
|
||||||
|
============================
|
||||||
|
|
||||||
|
Athena verwendet zwei Sicherungsebenen:
|
||||||
|
|
||||||
|
1. mike-ai-backup schreibt alle fünf Stunden ein lokales Schnellbackup nach
|
||||||
|
/data/docker-backups. Darin liegen /etc/mike-ai, ganz /opt/mike-ai sowie
|
||||||
|
Router- und Portainer-Zustand. Dieses Backup deckt den Ausfall der
|
||||||
|
Systemplatte ab, solange /data erhalten bleibt.
|
||||||
|
|
||||||
|
2. athena-disaster-backup schreibt nachts ein verschlüsseltes und
|
||||||
|
dedupliziertes Restic-Backup auf einen physisch anderen Speicher. Es enthält
|
||||||
|
zusätzlich eigene Stimmen, Trainingsdatensätze, Musik, Audioergebnisse,
|
||||||
|
Dashboard- und Projektdaten. Dieses Backup deckt den Ausfall der Datenplatte
|
||||||
|
und den gleichzeitigen Ausfall beider Platten ab.
|
||||||
|
|
||||||
|
Die reproduzierbaren Modellgewichte unter /data/models werden nicht extern
|
||||||
|
doppelt gespeichert. Bei Verlust der Datenplatte werden sie aus den
|
||||||
|
versionierten Quellen neu geladen. Das Whisper-Volume wird ebenfalls neu
|
||||||
|
erzeugt.
|
||||||
|
|
||||||
|
Nach Debian-Installation und dem Einhängen einer eigenen /data-Partition führt
|
||||||
|
disaster-recovery.sh den passenden Wiederaufbau aus:
|
||||||
|
|
||||||
|
- --scenario system: Systemplatte neu, alte Datenplatte vorhanden
|
||||||
|
- --scenario data: Datenplatte neu, Systemplatte vorhanden
|
||||||
|
- --scenario all: beide Platten neu
|
||||||
|
|
||||||
|
Das Skript formatiert keine Platten, führt keinen Neustart aus und beendet den
|
||||||
|
Wiederaufbau im sicheren LLM-Standardmodus. Details stehen in docs/RECOVERY.md.
|
||||||
|
|
||||||
|
Zusätzlich entstehen alle fünf Stunden unter /data/emergency-backups bis zu
|
||||||
|
fünf verschlüsselte Notfallpakete. Sie können mit Prüfsumme direkt aus dem
|
||||||
|
Athena-Dashboard heruntergeladen werden. Ein auf einen anderen Rechner
|
||||||
|
heruntergeladenes Paket kann statt des externen Restic-Speichers als Quelle
|
||||||
|
für den Daten- oder Totalausfall dienen. Auf /data verbliebene Pakete schützen
|
||||||
|
nicht gegen den Ausfall genau dieser Datenplatte.
|
||||||
|
|
||||||
|
|
||||||
|
ENTFERNTE KOMPONENTEN
|
||||||
|
=====================
|
||||||
|
|
||||||
|
- Beta 1 / qwen-beta-1: Profil, Containerdefinition, Container und
|
||||||
|
GSQ-RCO-IQ3_S-Modellgewichte entfernt. Der historische Testbericht bleibt
|
||||||
|
erhalten, damit das Modell nicht versehentlich erneut getestet wird.
|
||||||
|
- Piper: Containerdefinition, Container, Image, Datenvolume, Konfiguration und
|
||||||
|
WireGuard-Port 8091 entfernt.
|
||||||
|
|
||||||
|
|
||||||
|
WICHTIGES GRUNDPRINZIP
|
||||||
|
======================
|
||||||
|
|
||||||
|
Die Anwendungen laufen überwiegend in Docker-Containern. Container selbst
|
||||||
|
sind austauschbar und können aus den versionierten Stack-Dateien neu gebaut
|
||||||
|
werden. Modelle, Trainingsmaterial, Ergebnisse und betriebliche Einstellungen
|
||||||
|
liegen dagegen dauerhaft auf dem Debian-Host und werden in die Container
|
||||||
|
eingebunden.
|
||||||
|
|
||||||
|
Ein neu gebauter Container darf deshalb keine Nutzdaten vernichten. Für eine
|
||||||
|
vollständige Wiederherstellung werden jedoch sowohl das Git-Repository mit dem
|
||||||
|
Stack als auch eine Sicherung der dauerhaften Hostdaten benötigt.
|
||||||
@@ -10,9 +10,16 @@ Bild- und Sprachausgabe. **Hermes und die Fach-MCPs laufen auf Unraid.**
|
|||||||
|
|
||||||
- genau ein aktives llama.cpp-Profil: Fast, Medium, Large, Ultra oder Uncensored
|
- genau ein aktives llama.cpp-Profil: Fast, Medium, Large, Ultra oder Uncensored
|
||||||
- Profile Router auf Port 8081
|
- Profile Router auf Port 8081
|
||||||
- FLUX.2-klein-4B für Textbilder und Referenzbild-Bearbeitung auf der RTX 5080
|
- FLUX.2 Klein 9B FP8 Beta für Textbilder und Referenzbild-Bearbeitung:
|
||||||
- XTTS auf der RTX 3060 mit Piper als CPU-Fallback
|
Transformer auf RTX 5080, Qwen3-8B-NF4-Textencoder auf RTX 3060
|
||||||
|
- Qwen3-TTS 1.7B auf der RTX 3060 hinter einem Normalisierungs- und Streaming-Gateway
|
||||||
|
- Whisper.cpp `ggml-small` auf der CPU für lokale deutsche Spracherkennung
|
||||||
- Live-Dashboard mit 21 Tagen Detailhistorie auf Port 8099
|
- Live-Dashboard mit 21 Tagen Detailhistorie auf Port 8099
|
||||||
|
- Dashboard-Umschaltung zwischen LLM-Betrieb, ACE-Step-Musikstudio,
|
||||||
|
BS-RoFormer-Stimmtrennung, OmniVoice, X-VC, Applio/RVC und TRELLIS.2
|
||||||
|
- TRELLIS.2 4B Q8 über trellis.cpp 0.6.0 für lokale Bild-zu-3D-Erzeugung
|
||||||
|
auf der RTX 5080
|
||||||
|
- Portainer CE als optionale Container-Ansicht auf Port 9443
|
||||||
- WireGuard-Gateway, Datenbackup und Athena-Operator
|
- WireGuard-Gateway, Datenbackup und Athena-Operator
|
||||||
- keine produktive Hermes-, OpenWebUI- oder portable Fach-MCP-Instanz
|
- keine produktive Hermes-, OpenWebUI- oder portable Fach-MCP-Instanz
|
||||||
|
|
||||||
@@ -38,6 +45,11 @@ sudo ./install.sh --config /root/mike-ai-install.env
|
|||||||
|
|
||||||
Das Installationsskript baut llama.cpp und die lokalen Images, lädt die
|
Das Installationsskript baut llama.cpp und die lokalen Images, lädt die
|
||||||
versionierten Modellartefakte und startet ausschließlich den Athena-Kern.
|
versionierten Modellartefakte und startet ausschließlich den Athena-Kern.
|
||||||
|
FLUX.2 Klein 9B ist bei Hugging Face zugriffsbeschränkt. Vor der Installation
|
||||||
|
müssen die Bedingungen beider BFL-Repositories akzeptiert und ein Token in der
|
||||||
|
unter `HF_TOKEN_FILE` konfigurierten, nur für root lesbaren Datei abgelegt sein.
|
||||||
|
Der Token wird ausschließlich als Read-only-Datei in den Download-Container
|
||||||
|
eingehängt und weder in `stack.env` noch in Git kopiert.
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
@@ -58,10 +70,98 @@ versionierten Modellartefakte und startet ausschließlich den Athena-Kern.
|
|||||||
sudo ./smoke-test.sh
|
sudo ./smoke-test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Reasoning-Stufen
|
||||||
|
|
||||||
|
Der Router übersetzt die Auswahl eines OpenAI-kompatiblen Clients in echte,
|
||||||
|
pro Anfrage geltende llama.cpp-Denkbudgets. `Off` deaktiviert Thinking; die
|
||||||
|
aktiven Stufen sind auf 256 (Minimal), 768 (Low), 2048 (Medium), 4096 (High)
|
||||||
|
und 8192 Tokens (XHigh/Max/Ultra) begrenzt. Die Modellserver dürfen deshalb
|
||||||
|
kein festes `--reasoning-budget` setzen, da dieses die dynamischen Budgets
|
||||||
|
von llama.cpp übersteuern würde. Clients, die direkt
|
||||||
|
`thinking_budget_tokens` senden, behalten ihren expliziten Wert.
|
||||||
|
|
||||||
|
### Ein oder zwei Modell-Slots
|
||||||
|
|
||||||
|
Produktiv laufen alle Profile mit einem Slot. Damit erhält ein einzelner Chat
|
||||||
|
den vollständigen Profilkontext und die bewährte Ausgabegeschwindigkeit. Die
|
||||||
|
Einstellung liegt auf Athena in `/etc/mike-ai/stack.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MEDIUM_PARALLEL_SLOTS=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Für einen späteren erneuten Paralleltest genügt es, den Wert auf `2` zu setzen
|
||||||
|
und ausschließlich das aktuell betroffene Profil neu zu erstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i 's/^MEDIUM_PARALLEL_SLOTS=.*/MEDIUM_PARALLEL_SLOTS=2/' /etc/mike-ai/stack.env
|
||||||
|
cd /opt/mike-ai/stack
|
||||||
|
docker compose --env-file /etc/mike-ai/stack.env up -d --no-deps --force-recreate llama-medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Zurück zum stabilen Ein-Slot-Betrieb geht es mit denselben zwei Befehlen und
|
||||||
|
`MEDIUM_PARALLEL_SLOTS=1`. `--kv-unified` ist bereits im Compose-Stack gesetzt.
|
||||||
|
Zwei Slots wurden direkt am Router erfolgreich getestet; Hermes verwaltete zwei
|
||||||
|
gleichzeitig aktive Chats jedoch nicht zuverlässig. Deshalb bleibt ein Slot der
|
||||||
|
Standard, bis Hermes' Sitzungsfehler behoben ist.
|
||||||
|
|
||||||
|
### Bildgenerierung mit FLUX.2 Klein 9B FP8 Beta
|
||||||
|
|
||||||
|
Ein Bildauftrag verwendet beide GPUs exklusiv. Der Profile Controller stoppt
|
||||||
|
zuerst das aktive llama.cpp-Profil und Qwen3-TTS. Anschließend läuft der
|
||||||
|
FP8-Transformer auf der RTX 5080 und der in NF4 geladene Qwen3-8B-Textencoder
|
||||||
|
auf der RTX 3060. Vor dem VAE-Decoding werden Transformer und Textencoder
|
||||||
|
freigegeben. Nach dem Bildauftrag stoppt der Router den Bild-Worker und stellt
|
||||||
|
Qwen3-TTS sowie das zuvor aktive Textprofil automatisch wieder her. Während
|
||||||
|
der exklusiven Nutzung der RTX 3060 ist TTS vorübergehend nicht verfügbar.
|
||||||
|
|
||||||
|
Die Beta ist derzeit bewusst auf `1024x1024`, vier Schritte, Guidance `1.0`,
|
||||||
|
einen parallelen Auftrag und maximal vier lokale Referenzbilder begrenzt.
|
||||||
|
Details, Installation, Prüfung und Rollback stehen in
|
||||||
|
[docs/FLUX_9B_BETA.md](docs/FLUX_9B_BETA.md).
|
||||||
|
|
||||||
## Endpunkte
|
## Endpunkte
|
||||||
|
|
||||||
- Router: `http://192.168.1.212:8081/v1`
|
- Router: `http://192.168.1.212:8081/v1`
|
||||||
- Athena-Dashboard: `http://192.168.1.212:8099`
|
- Athena-Dashboard: `http://192.168.1.212:8099`
|
||||||
|
- Musikstudio, Original UI (stabil): `http://192.168.1.212:7862`
|
||||||
|
- Musikstudio, Community UI (experimentell): `http://192.168.1.212:7861`
|
||||||
|
- Spuren trennen (BS-RoFormer + Demucs, 2/4/6 Stems): `http://192.168.1.212:8007`
|
||||||
|
- Voice Studio (OmniVoice, Text zu Stimme): `http://192.168.1.212:8008`
|
||||||
|
- Voice Changer (X-VC, Audio zu Audio; native 16 kHz plus optional restaurierte 44,1 kHz): `http://192.168.1.212:8009`
|
||||||
|
- Applio (RVC-Inferenz, Modelle und Training): `http://192.168.1.212:8011`
|
||||||
|
- Mikes Applio UI (geführte RVC-Oberfläche): `http://192.168.1.212:8012`
|
||||||
|
- 3D Studio (TRELLIS.2 Q8, GLB-Ausgabe): `http://192.168.1.212:8013`
|
||||||
|
- YuE2 Studio (YuE2-3B, Generierung und Audio-Remix): `http://192.168.1.212:8014`
|
||||||
|
|
||||||
|
Der Betriebsmodus lässt sich dort direkt umschalten. In Hermes funktionieren
|
||||||
|
außerdem `/athena music`, `/athena stems`, `/athena voice`,
|
||||||
|
`/athena voicechange`, `/athena applio`, `/athena 3d`, `/athena llm` und
|
||||||
|
`/athena status`; Details stehen in
|
||||||
|
[docs/OPERATING_MODES.md](docs/OPERATING_MODES.md).
|
||||||
|
|
||||||
|
Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über
|
||||||
|
`/v1/audio/speech`, natives Qwen-PCM-Streaming über
|
||||||
|
`/v1/audio/speech/pcm-stream` und Spracherkennung über
|
||||||
|
`/v1/audio/transcriptions`. Das
|
||||||
|
Whisper-Modell liegt persistent im Docker-Volume `whisper-data`; Audiodaten
|
||||||
|
werden lokal auf Athena verarbeitet. Für OpenClaw Talk liegt der lokale
|
||||||
|
Realtime-Provider unter
|
||||||
|
[`integrations/openclaw-athena-talk`](integrations/openclaw-athena-talk). Er
|
||||||
|
verbindet Mikrofon → Athena Whisper → normalen OpenClaw-Agenten → aktives
|
||||||
|
Athena-TTS, sodass Modell, Werkzeuge und Memory auch im Sprachmodus erhalten
|
||||||
|
bleiben. Die Installation landet in OpenClaws persistentem Datenverzeichnis
|
||||||
|
und bleibt deshalb bei normalen Container-Updates bestehen.
|
||||||
|
|
||||||
|
Für Hermes liegt unter `integrations/hermes-qwen3-stream` ein optionales,
|
||||||
|
persistentes Backend-Plugin. Es nutzt den nativen PCM-Strom und verkürzt den
|
||||||
|
Beginn der Sprachausgabe, ohne den Modellrouter oder die Textprofile zu ändern.
|
||||||
|
Bildgenerierung läuft über `/v1/images/generations`; Hermes verwendet dafür den
|
||||||
|
persistenten Benutzer-Provider `athena-local` mit dem Modellnamen
|
||||||
|
`FLUX.2-klein-9B-fp8-beta`. Seine versionierte Quelle und Installationshinweise
|
||||||
|
liegen unter
|
||||||
|
[`integrations/hermes-athena-image`](integrations/hermes-athena-image).
|
||||||
|
- Portainer: `https://192.168.1.212:9443`
|
||||||
- Hermes-Dashboard auf Unraid: `http://192.168.1.2:9119`
|
- Hermes-Dashboard auf Unraid: `http://192.168.1.2:9119`
|
||||||
|
|
||||||
Die Adressen sind nur über die vorgesehenen privaten Netze erreichbar.
|
Die Adressen sind nur über die vorgesehenen privaten Netze erreichbar.
|
||||||
@@ -78,21 +178,28 @@ Unraid-DockerMan-Templates. Details stehen in
|
|||||||
|
|
||||||
## Wiederherstellung
|
## Wiederherstellung
|
||||||
|
|
||||||
Nach einer frischen Debian-Installation und erneut eingehängtem `/data`:
|
Nach einer frischen Debian-Installation und separat eingehängtem `/data`
|
||||||
|
übernimmt ein Orchestrator den vollständigen Wiederaufbau. Beispiel bei
|
||||||
|
erhaltener Datenplatte:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./install.sh --config /root/mike-ai-install.env
|
sudo ./disaster-recovery.sh --scenario system \
|
||||||
sudo ./restore.sh /data/docker-backups/athena-latest.tar.gz
|
--archive /data/docker-backups/athena-latest.tar.gz
|
||||||
sudo ./smoke-test.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Der genaue Sicherungsumfang steht in [docs/RECOVERY.md](docs/RECOVERY.md).
|
Für den Ausfall der Datenplatte oder beider Platten wird das verschlüsselte
|
||||||
|
externe Restic-Backup verwendet. Der genaue Sicherungsumfang und alle drei
|
||||||
|
Szenarien stehen in [docs/RECOVERY.md](docs/RECOVERY.md).
|
||||||
|
|
||||||
## Verbindliche Dokumentation
|
## Verbindliche Dokumentation
|
||||||
|
|
||||||
- [ATHENA.md](ATHENA.md) – kurze Betriebsanleitung
|
- [ATHENA.md](ATHENA.md) – kurze Betriebsanleitung
|
||||||
|
- [for_ki.md](for_ki.md) – verbindlicher System- und Änderungsleitfaden für KI-Agenten
|
||||||
- [docs/STANDARD_PROFILE_MATRIX.md](docs/STANDARD_PROFILE_MATRIX.md) – Profile
|
- [docs/STANDARD_PROFILE_MATRIX.md](docs/STANDARD_PROFILE_MATRIX.md) – Profile
|
||||||
|
- [docs/CONTAINER_INVENTORY.md](docs/CONTAINER_INVENTORY.md) – alle Container, Modelle und Aufgaben
|
||||||
|
- [docs/TESTED_MODELS.md](docs/TESTED_MODELS.md) – zentrale Testhistorie und Sperrliste gegen Doppeltests
|
||||||
- [docs/MCP_SERVERS.md](docs/MCP_SERVERS.md) – produktive Werkzeuge
|
- [docs/MCP_SERVERS.md](docs/MCP_SERVERS.md) – produktive Werkzeuge
|
||||||
- [docs/RECOVERY.md](docs/RECOVERY.md) – Backup und Neuaufbau
|
- [docs/RECOVERY.md](docs/RECOVERY.md) – Backup und Neuaufbau
|
||||||
|
- [docs/FLUX_9B_BETA.md](docs/FLUX_9B_BETA.md) – 9B-Bildpfad, Test und Rollback
|
||||||
|
|
||||||
Git enthält keine Secrets, Chatdaten oder Modellgewichte.
|
Git enthält keine Secrets, Chatdaten oder Modellgewichte.
|
||||||
+132
-135
@@ -99,7 +99,7 @@ services:
|
|||||||
# disabled until the current upstream restore regressions are fixed.
|
# disabled until the current upstream restore regressions are fixed.
|
||||||
- --cache-prompt
|
- --cache-prompt
|
||||||
- --cache-ram
|
- --cache-ram
|
||||||
- "${LLAMA_CACHE_RAM_MIB:-24576}"
|
- "${LLAMA_CACHE_RAM_MIB:-32768}"
|
||||||
- --threads
|
- --threads
|
||||||
- "${LLAMA_THREADS:-6}"
|
- "${LLAMA_THREADS:-6}"
|
||||||
- --threads-batch
|
- --threads-batch
|
||||||
@@ -153,8 +153,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NVIDIA_VISIBLE_DEVICES: ${MEDIUM_GPU_DEVICES:-0,1}
|
NVIDIA_VISIBLE_DEVICES: ${MEDIUM_GPU_DEVICES:-0,1}
|
||||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
# Keep the language model split unchanged while placing the complete
|
|
||||||
# multimodal projector on the secondary RTX 3060.
|
|
||||||
MTMD_BACKEND_DEVICE: CUDA1
|
MTMD_BACKEND_DEVICE: CUDA1
|
||||||
command:
|
command:
|
||||||
- --model
|
- --model
|
||||||
@@ -176,7 +174,7 @@ services:
|
|||||||
- q4_0
|
- q4_0
|
||||||
- --cache-prompt
|
- --cache-prompt
|
||||||
- --cache-ram
|
- --cache-ram
|
||||||
- "${LLAMA_CACHE_RAM_MIB:-24576}"
|
- "${LLAMA_CACHE_RAM_MIB:-32768}"
|
||||||
- --threads
|
- --threads
|
||||||
- "${LLAMA_THREADS:-6}"
|
- "${LLAMA_THREADS:-6}"
|
||||||
- --threads-batch
|
- --threads-batch
|
||||||
@@ -186,16 +184,13 @@ services:
|
|||||||
- --ubatch-size
|
- --ubatch-size
|
||||||
- "${MEDIUM_UBATCH_SIZE:-128}"
|
- "${MEDIUM_UBATCH_SIZE:-128}"
|
||||||
- --parallel
|
- --parallel
|
||||||
- "${MEDIUM_PARALLEL_SLOTS:-2}"
|
- "${MEDIUM_PARALLEL_SLOTS:-1}"
|
||||||
- --kv-unified
|
- --kv-unified
|
||||||
- --jinja
|
- --jinja
|
||||||
- --reasoning
|
- --reasoning
|
||||||
- auto
|
- auto
|
||||||
# Bound each individual thinking phase. Long agent jobs can still use
|
# No fixed --reasoning-budget here: the router supplies a real budget
|
||||||
# many phases around tool calls, but one degenerate reasoning loop can
|
# per request from the client's reasoning_effort selection.
|
||||||
# no longer consume the complete response budget indefinitely.
|
|
||||||
- --reasoning-budget
|
|
||||||
- "8192"
|
|
||||||
- --reasoning-preserve
|
- --reasoning-preserve
|
||||||
- --host
|
- --host
|
||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
@@ -264,7 +259,7 @@ services:
|
|||||||
- q4_0
|
- q4_0
|
||||||
- --cache-prompt
|
- --cache-prompt
|
||||||
- --cache-ram
|
- --cache-ram
|
||||||
- "${LLAMA_CACHE_RAM_MIB:-24576}"
|
- "${LLAMA_CACHE_RAM_MIB:-32768}"
|
||||||
- --threads
|
- --threads
|
||||||
- "${LLAMA_THREADS:-6}"
|
- "${LLAMA_THREADS:-6}"
|
||||||
- --threads-batch
|
- --threads-batch
|
||||||
@@ -342,7 +337,7 @@ services:
|
|||||||
- --cache-reuse
|
- --cache-reuse
|
||||||
- "${LLAMA_CACHE_REUSE:-256}"
|
- "${LLAMA_CACHE_REUSE:-256}"
|
||||||
- --cache-ram
|
- --cache-ram
|
||||||
- "${LLAMA_CACHE_RAM_MIB:-24576}"
|
- "${LLAMA_CACHE_RAM_MIB:-32768}"
|
||||||
- --threads
|
- --threads
|
||||||
- "${LLAMA_THREADS:-6}"
|
- "${LLAMA_THREADS:-6}"
|
||||||
- --threads-batch
|
- --threads-batch
|
||||||
@@ -424,7 +419,7 @@ services:
|
|||||||
- q4_0
|
- q4_0
|
||||||
- --cache-prompt
|
- --cache-prompt
|
||||||
- --cache-ram
|
- --cache-ram
|
||||||
- "${LLAMA_CACHE_RAM_MIB:-24576}"
|
- "${LLAMA_CACHE_RAM_MIB:-32768}"
|
||||||
- --threads
|
- --threads
|
||||||
- "${LLAMA_THREADS:-6}"
|
- "${LLAMA_THREADS:-6}"
|
||||||
- --threads-batch
|
- --threads-batch
|
||||||
@@ -476,55 +471,6 @@ services:
|
|||||||
- --spec-draft-type-v
|
- --spec-draft-type-v
|
||||||
- f16
|
- f16
|
||||||
|
|
||||||
llama-experimental:
|
|
||||||
<<: *llama-common
|
|
||||||
container_name: mike-ai-llama-experimental
|
|
||||||
labels:
|
|
||||||
com.mike-ai.llama-profile: experimental
|
|
||||||
environment:
|
|
||||||
NVIDIA_VISIBLE_DEVICES: ${EXPERIMENTAL_GPU_DEVICES:-0}
|
|
||||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
|
||||||
command:
|
|
||||||
- --model
|
|
||||||
- "/models/${EXPERIMENTAL_MODEL_FILE:?EXPERIMENTAL_MODEL_FILE is required}"
|
|
||||||
- --alias
|
|
||||||
- qwen-experimental
|
|
||||||
- --ctx-size
|
|
||||||
- "${EXPERIMENTAL_CONTEXT:-76800}"
|
|
||||||
- --flash-attn
|
|
||||||
- "on"
|
|
||||||
- --cache-type-k
|
|
||||||
- q4_0
|
|
||||||
- --cache-type-v
|
|
||||||
- q4_0
|
|
||||||
- --cache-prompt
|
|
||||||
- --cache-reuse
|
|
||||||
- "${LLAMA_CACHE_REUSE:-256}"
|
|
||||||
- --cache-ram
|
|
||||||
- "${LLAMA_CACHE_RAM_MIB:-24576}"
|
|
||||||
- --parallel
|
|
||||||
- "${EXPERIMENTAL_PARALLEL_SLOTS:-1}"
|
|
||||||
- --kv-unified
|
|
||||||
- --jinja
|
|
||||||
- --reasoning
|
|
||||||
- auto
|
|
||||||
- --reasoning-preserve
|
|
||||||
- --host
|
|
||||||
- 0.0.0.0
|
|
||||||
- --port
|
|
||||||
- "8080"
|
|
||||||
- --metrics
|
|
||||||
- --fit
|
|
||||||
- "off"
|
|
||||||
- --n-gpu-layers
|
|
||||||
- all
|
|
||||||
- --no-mmap
|
|
||||||
- --no-ui
|
|
||||||
- --device
|
|
||||||
- CUDA0
|
|
||||||
- --split-mode
|
|
||||||
- none
|
|
||||||
|
|
||||||
profile-controller:
|
profile-controller:
|
||||||
build: ./platform/docker/profile-controller
|
build: ./platform/docker/profile-controller
|
||||||
image: mike-ai/profile-controller:local
|
image: mike-ai/profile-controller:local
|
||||||
@@ -536,8 +482,17 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
CONTROLLER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
CONTROLLER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
||||||
ALLOWED_PROFILES: fast,medium,large,ultra,uncensored,experimental
|
ALLOWED_PROFILES: fast,medium,large,ultra,uncensored
|
||||||
IMAGE_WORKER: image
|
IMAGE_WORKER: image
|
||||||
|
RESTORE_WORKER: restore
|
||||||
|
TTS_WORKER: qwen3
|
||||||
|
MUSIC_WORKER: acestep
|
||||||
|
YUE2_WORKER: yue2
|
||||||
|
SEPARATOR_WORKER: bs-roformer
|
||||||
|
VOICE_WORKER: vevo2
|
||||||
|
VOICE_CHANGE_WORKER: xvc
|
||||||
|
APPLIO_WORKER: applio
|
||||||
|
TRELLIS_WORKER: trellis2-q8
|
||||||
networks: [control]
|
networks: [control]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -557,6 +512,7 @@ services:
|
|||||||
tmpfs: ["/tmp:size=256m"]
|
tmpfs: ["/tmp:size=256m"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./router/router_profiles.json:/etc/mike-ai/router-profiles.json:ro
|
- ./router/router_profiles.json:/etc/mike-ai/router-profiles.json:ro
|
||||||
|
- ./config/global-system-policy.txt:/etc/mike-ai/global-system-policy.txt:ro
|
||||||
- router-state:/var/lib/mike-ai-profile-router
|
- router-state:/var/lib/mike-ai-profile-router
|
||||||
- router-images:/data/images
|
- router-images:/data/images
|
||||||
environment:
|
environment:
|
||||||
@@ -572,27 +528,34 @@ services:
|
|||||||
PROFILE_CONTROL_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
PROFILE_CONTROL_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
||||||
SWITCH_TIMEOUT: "600"
|
SWITCH_TIMEOUT: "600"
|
||||||
REQUEST_TIMEOUT: "600"
|
REQUEST_TIMEOUT: "600"
|
||||||
|
YUE2_START_TIMEOUT: "600"
|
||||||
|
TRELLIS_START_TIMEOUT: "900"
|
||||||
# Last-resort guard for every OpenAI-compatible client. Without a
|
# Last-resort guard for every OpenAI-compatible client. Without a
|
||||||
# request limit llama.cpp uses n_predict=-1 and a reasoning loop can
|
# request limit llama.cpp uses n_predict=-1 and a reasoning loop can
|
||||||
# consume the complete context before yielding visible output.
|
# consume the complete context before yielding visible output.
|
||||||
MAX_GENERATION_TOKENS: "8192"
|
MAX_GENERATION_TOKENS: "8192"
|
||||||
DEFAULT_REASONING_EFFORT: "${DEFAULT_REASONING_EFFORT:-medium}"
|
DEFAULT_REASONING_EFFORT: "${DEFAULT_REASONING_EFFORT:-off}"
|
||||||
|
GLOBAL_SYSTEM_POLICY_FILE: /etc/mike-ai/global-system-policy.txt
|
||||||
IMAGE_DIR: /data/images
|
IMAGE_DIR: /data/images
|
||||||
IMAGE_WORKER_URL: http://image-worker:8086
|
IMAGE_WORKER_URL: http://image-worker:8086
|
||||||
IMAGE_WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
IMAGE_WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
||||||
IMAGE_MODEL_NAME: FLUX.2-klein-4B
|
IMAGE_MODEL_NAME: FLUX.2-klein-9B-fp8-beta
|
||||||
CHAT_IMAGE_ALLOW_REMOTE_URLS: "false"
|
CHAT_IMAGE_ALLOW_REMOTE_URLS: "false"
|
||||||
ENABLE_IMAGE_GENERATION: "true"
|
ENABLE_IMAGE_GENERATION: "true"
|
||||||
ENABLE_TTS: "true"
|
ENABLE_TTS: "true"
|
||||||
# Stable OpenAI compatibility names remain piper/alloy because an
|
# The gateway keeps text normalization, output conversion and native
|
||||||
# existing Open WebUI database persists those values. The gateway maps
|
# PCM streaming in one stable API in front of Qwen3-TTS.
|
||||||
# alloy to XTTS speaker Annmarie Nele and automatically falls back to
|
|
||||||
# Piper if XTTS is unavailable, busy or returns an error.
|
|
||||||
TTS_WORKER_URL: http://tts-gateway:8085
|
TTS_WORKER_URL: http://tts-gateway:8085
|
||||||
TTS_MODEL: piper
|
TTS_MODEL: qwen3-tts
|
||||||
TTS_VOICES: alloy
|
TTS_VOICES: alloy
|
||||||
TTS_DEFAULT_VOICE: alloy
|
TTS_DEFAULT_VOICE: alloy
|
||||||
ENABLE_STT: "false"
|
ENABLE_STT: "true"
|
||||||
|
ENABLE_MUSIC_MODE: "true"
|
||||||
|
MUSIC_START_TIMEOUT: "600"
|
||||||
|
VOICE_CHANGE_START_TIMEOUT: "600"
|
||||||
|
APPLIO_START_TIMEOUT: "900"
|
||||||
|
STT_WORKER_URL: http://whisper:8084
|
||||||
|
STT_TIMEOUT: "300"
|
||||||
networks: [frontend, control, inference]
|
networks: [frontend, control, inference]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
@@ -612,10 +575,10 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
profile-controller:
|
profile-controller:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
piper:
|
|
||||||
condition: service_healthy
|
|
||||||
tts-gateway:
|
tts-gateway:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
whisper:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
image-worker:
|
image-worker:
|
||||||
build:
|
build:
|
||||||
@@ -635,13 +598,15 @@ services:
|
|||||||
read_only: true
|
read_only: true
|
||||||
tmpfs: ["/tmp:size=1g,mode=1777"]
|
tmpfs: ["/tmp:size=1g,mode=1777"]
|
||||||
volumes:
|
volumes:
|
||||||
- "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}:/models/FLUX.2-klein-4B:ro"
|
- "${FLUX_COMPONENT_DIR:-/data/models/FLUX.2-klein-9B-components}:/models/components:ro"
|
||||||
|
- "${FLUX_TRANSFORMER_DIR:-/data/models/FLUX.2-klein-9B-fp8}:/models/fp8:ro"
|
||||||
- router-images:/data/images
|
- router-images:/data/images
|
||||||
environment:
|
environment:
|
||||||
NVIDIA_VISIBLE_DEVICES: ${IMAGE_GPU_DEVICES:-1}
|
NVIDIA_VISIBLE_DEVICES: all
|
||||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
|
||||||
FLUX_MODEL_DIR: /models/FLUX.2-klein-4B
|
FLUX_COMPONENT_DIR: /models/components
|
||||||
|
FLUX_TRANSFORMER_FILE: /models/fp8/flux-2-klein-9b-fp8.safetensors
|
||||||
IMAGE_DIR: /data/images
|
IMAGE_DIR: /data/images
|
||||||
networks: [inference]
|
networks: [inference]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
@@ -652,71 +617,45 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
|
||||||
piper:
|
qwen3-tts:
|
||||||
build:
|
image: ${QWEN3_TTS_IMAGE:-ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98}
|
||||||
context: platform/docker/piper
|
container_name: mike-ai-qwen3-tts
|
||||||
args:
|
|
||||||
PIPER_TTS_VERSION: ${PIPER_TTS_VERSION:-1.6.0}
|
|
||||||
image: mike-ai/piper:local
|
|
||||||
container_name: mike-ai-piper
|
|
||||||
restart: unless-stopped
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:size=256m,mode=1777
|
|
||||||
volumes:
|
|
||||||
- piper-data:/data
|
|
||||||
environment:
|
|
||||||
PIPER_DATA_DIR: /data
|
|
||||||
PIPER_VOICE: ${PIPER_VOICE:-de_DE-thorsten-high}
|
|
||||||
PIPER_VOICE_ALIAS: alloy
|
|
||||||
PIPER_HOST: 0.0.0.0
|
|
||||||
PIPER_PORT: "8085"
|
|
||||||
PIPER_MAX_TEXT_CHARS: "8000"
|
|
||||||
networks: [frontend]
|
|
||||||
security_opt: ["no-new-privileges:true"]
|
|
||||||
cap_drop: [ALL]
|
|
||||||
cap_add: [CHOWN, SETUID, SETGID]
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD, curl, -fsS, "http://127.0.0.1:8085/status"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 120s
|
|
||||||
|
|
||||||
xtts:
|
|
||||||
image: ${XTTS_IMAGE:-ghcr.io/coqui-ai/xtts-streaming-server:latest-cuda121@sha256:f7fb3b1f9d4bc88af94da1b5959d8002f1e0b003c97557164034eb8a29f01b90}
|
|
||||||
container_name: mike-ai-xtts
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
com.mike-ai.tts-worker: qwen3
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
reservations:
|
reservations:
|
||||||
devices:
|
devices:
|
||||||
- driver: nvidia
|
- driver: nvidia
|
||||||
device_ids:
|
device_ids:
|
||||||
- ${XTTS_GPU_DEVICE:-GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b}
|
- ${QWEN3_TTS_GPU_DEVICE:-GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b}
|
||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
read_only: true
|
read_only: true
|
||||||
shm_size: 1g
|
shm_size: 1g
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=1g,mode=1777
|
- /tmp:size=1g,mode=1777
|
||||||
- /root/.cache:size=2g,mode=0700
|
|
||||||
volumes:
|
volumes:
|
||||||
- "${XTTS_CACHE_DIR:-/data/models/xtts-v2-cache}:/root/.local/share/tts"
|
- "${QWEN3_TTS_CACHE_DIR:-/data/models/qwen3-tts-cache}:/root/.cache/huggingface"
|
||||||
|
- "${QWEN3_TTS_VOICES_DIR:-/data/models/qwen3-tts-voices}:/data/voices"
|
||||||
environment:
|
environment:
|
||||||
COQUI_TOS_AGREED: "1"
|
NVIDIA_VISIBLE_DEVICES: ${QWEN3_TTS_GPU_DEVICE:-GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b}
|
||||||
NVIDIA_VISIBLE_DEVICES: ${XTTS_GPU_DEVICE:-GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b}
|
|
||||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
CUDA_VISIBLE_DEVICES: "0"
|
CUDA_VISIBLE_DEVICES: "0"
|
||||||
NUM_THREADS: "4"
|
HF_HOME: /root/.cache/huggingface
|
||||||
|
NUMBA_CACHE_DIR: /tmp/numba
|
||||||
|
QWEN3_TTS_MODEL_ID: Qwen/Qwen3-TTS-12Hz-1.7B-Base
|
||||||
|
QWEN3_TTS_DEFAULT_VOICE: serena
|
||||||
|
QWEN3_TTS_VOICES_DIR: /data/voices
|
||||||
networks: [frontend]
|
networks: [frontend]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [CMD, curl, -fsS, "http://127.0.0.1/languages"]
|
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=2)"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 36
|
retries: 60
|
||||||
start_period: 240s
|
start_period: 600s
|
||||||
|
|
||||||
tts-gateway:
|
tts-gateway:
|
||||||
build:
|
build:
|
||||||
@@ -730,24 +669,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TTS_GATEWAY_HOST: 0.0.0.0
|
TTS_GATEWAY_HOST: 0.0.0.0
|
||||||
TTS_GATEWAY_PORT: "8085"
|
TTS_GATEWAY_PORT: "8085"
|
||||||
XTTS_URL: http://xtts:80
|
QWEN_TTS_URL: http://qwen3-tts:8001
|
||||||
PIPER_URL: http://piper:8085
|
QWEN_TTS_MODEL: tts-1
|
||||||
|
QWEN_TTS_VOICE: serena
|
||||||
|
QWEN_TTS_LANGUAGE: German
|
||||||
|
QWEN_TTS_TIMEOUT: "120"
|
||||||
TTS_VOICE_ALIAS: alloy
|
TTS_VOICE_ALIAS: alloy
|
||||||
XTTS_SPEAKER: Annmarie Nele
|
|
||||||
TTS_DEFAULT_LANGUAGE: de
|
TTS_DEFAULT_LANGUAGE: de
|
||||||
# Mixed-language clip stitching caused long pauses and unintelligible
|
# Mixed-language clip stitching caused long pauses and unintelligible
|
||||||
# transitions. Keep full sentences in one stable German voice.
|
# transitions. Keep full sentences in one stable German voice.
|
||||||
TTS_CODE_SWITCH_ENABLED: "false"
|
TTS_CODE_SWITCH_ENABLED: "false"
|
||||||
XTTS_QUEUE_TIMEOUT: "15"
|
|
||||||
XTTS_TIMEOUT: "120"
|
|
||||||
# Short sentence-sized requests avoid long generated silences and
|
|
||||||
# truncated weather/status summaries with Annmarie Nele.
|
|
||||||
XTTS_CHUNK_CHARS: "60"
|
|
||||||
PIPER_TIMEOUT: "120"
|
|
||||||
networks: [frontend]
|
networks: [frontend]
|
||||||
depends_on:
|
|
||||||
piper:
|
|
||||||
condition: service_healthy
|
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -757,12 +689,47 @@ services:
|
|||||||
retries: 12
|
retries: 12
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
whisper:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: platform/docker/whisper/Dockerfile
|
||||||
|
args:
|
||||||
|
WHISPER_CPP_VERSION: ${WHISPER_CPP_VERSION:-v1.9.1}
|
||||||
|
image: mike-ai/whisper:local
|
||||||
|
container_name: mike-ai-whisper
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=2g,mode=1777
|
||||||
|
volumes:
|
||||||
|
- whisper-data:/models
|
||||||
|
environment:
|
||||||
|
WHISPER_HOST: 0.0.0.0
|
||||||
|
WHISPER_PORT: "8084"
|
||||||
|
WHISPER_CLI: /opt/whisper.cpp/build/bin/whisper-cli
|
||||||
|
WHISPER_MODEL: /models/ggml-small.bin
|
||||||
|
WHISPER_MODEL_URL: ${WHISPER_MODEL_URL:-https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin}
|
||||||
|
WHISPER_SERVER_PORT: "8085"
|
||||||
|
WHISPER_THREADS: ${WHISPER_THREADS:-8}
|
||||||
|
WHISPER_LANGUAGE: ${WHISPER_LANGUAGE:-de}
|
||||||
|
networks: [frontend, inference]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
# The entrypoint supervises whisper-server after dropping it to uid 10004.
|
||||||
|
cap_add: [CHOWN, SETUID, SETGID, KILL]
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, curl, -fsS, "http://127.0.0.1:8084/status"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 90
|
||||||
|
start_period: 20m
|
||||||
|
|
||||||
llama-dashboard:
|
llama-dashboard:
|
||||||
build: ./platform/llama-dashboard
|
build: ./platform/llama-dashboard
|
||||||
image: mike-ai/llama-dashboard:local
|
image: mike-ai/llama-dashboard:local
|
||||||
container_name: mike-ai-llama-dashboard
|
container_name: mike-ai-llama-dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: "service:wireguard-gateway"
|
networks: [frontend]
|
||||||
gpus: all
|
gpus: all
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
@@ -771,15 +738,26 @@ services:
|
|||||||
- /proc:/host/proc:ro
|
- /proc:/host/proc:ro
|
||||||
- /data:/host/data:ro
|
- /data:/host/data:ro
|
||||||
- /data/models:/host/models:ro
|
- /data/models:/host/models:ro
|
||||||
|
- /data/emergency-backups:/backups:ro
|
||||||
- /data/llama-dashboard:/var/lib/llama-dashboard
|
- /data/llama-dashboard:/var/lib/llama-dashboard
|
||||||
environment:
|
environment:
|
||||||
DASHBOARD_HOST: 0.0.0.0
|
DASHBOARD_HOST: 0.0.0.0
|
||||||
DASHBOARD_PORT: "8099"
|
DASHBOARD_PORT: "8099"
|
||||||
ROUTER_URL: http://router:8081
|
ROUTER_URL: http://router:8081
|
||||||
ROUTER_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
|
ROUTER_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}"
|
||||||
|
MUSIC_COMMUNITY_UI_URL: "${MUSIC_COMMUNITY_UI_URL:-http://192.168.1.212:7861/}"
|
||||||
|
MUSIC_ORIGINAL_UI_URL: "${MUSIC_ORIGINAL_UI_URL:-http://192.168.1.212:7862/}"
|
||||||
|
SEPARATOR_UI_URL: "${SEPARATOR_UI_URL:-http://192.168.1.212:8007/}"
|
||||||
|
VOICE_UI_URL: "${VOICE_UI_URL:-http://192.168.1.212:8008/}"
|
||||||
|
VOICE_CHANGE_UI_URL: "${VOICE_CHANGE_UI_URL:-http://192.168.1.212:8009/}"
|
||||||
|
APPLIO_UI_URL: "${APPLIO_UI_URL:-http://192.168.1.212:8011/}"
|
||||||
|
MIKES_APPLIO_UI_URL: "${MIKES_APPLIO_UI_URL:-http://192.168.1.212:8012/}"
|
||||||
|
TRELLIS_UI_URL: "${TRELLIS_UI_URL:-http://192.168.1.212:8013/}"
|
||||||
|
YUE2_UI_URL: "${YUE2_UI_URL:-http://192.168.1.212:8014/}"
|
||||||
HOST_PROC: /host/proc
|
HOST_PROC: /host/proc
|
||||||
HOST_DATA: /host/data
|
HOST_DATA: /host/data
|
||||||
HOST_MODELS: /host/models
|
HOST_MODELS: /host/models
|
||||||
|
DASHBOARD_BACKUP_DIR: /backups
|
||||||
DASHBOARD_HISTORY_DB: /var/lib/llama-dashboard/history.sqlite3
|
DASHBOARD_HISTORY_DB: /var/lib/llama-dashboard/history.sqlite3
|
||||||
DASHBOARD_HISTORY_INTERVAL: "15"
|
DASHBOARD_HISTORY_INTERVAL: "15"
|
||||||
DASHBOARD_DETAIL_RETENTION_DAYS: "21"
|
DASHBOARD_DETAIL_RETENTION_DAYS: "21"
|
||||||
@@ -799,6 +777,20 @@ services:
|
|||||||
retries: 12
|
retries: 12
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
portainer:
|
||||||
|
image: ${PORTAINER_IMAGE:-portainer/portainer-ce@sha256:511f3f06c96fe3b993ebeaafde311c1959cae73a7ef825dba6397d51b450dffa}
|
||||||
|
container_name: mike-ai-portainer
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [frontend]
|
||||||
|
command: [--no-setup-token]
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- portainer-data:/data
|
||||||
|
depends_on:
|
||||||
|
wireguard-gateway:
|
||||||
|
condition: service_healthy
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
backup:
|
backup:
|
||||||
image: ${BACKUP_IMAGE:-offen/docker-volume-backup@sha256:19102d8e59eb1d598cf8c647c2b21100abaadc5a1c808ac643fa612e323c3013}
|
image: ${BACKUP_IMAGE:-offen/docker-volume-backup@sha256:19102d8e59eb1d598cf8c647c2b21100abaadc5a1c808ac643fa612e323c3013}
|
||||||
container_name: mike-ai-backup
|
container_name: mike-ai-backup
|
||||||
@@ -813,10 +805,12 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- /data/docker-backups:/archive
|
- /data/docker-backups:/archive
|
||||||
- /etc/mike-ai:/backup/etc-mike-ai:ro
|
- /etc/mike-ai:/backup/etc-mike-ai:ro
|
||||||
- /opt/mike-ai/stack:/backup/stack:ro
|
# Include every deployed specialist UI/worker source tree, not just the
|
||||||
- piper-data:/backup/volumes/piper-data:ro
|
# core checkout. Images themselves remain reproducible and are rebuilt.
|
||||||
|
- /opt/mike-ai:/backup/opt-mike-ai:ro
|
||||||
- router-state:/backup/volumes/router-state:ro
|
- router-state:/backup/volumes/router-state:ro
|
||||||
- router-images:/backup/volumes/router-images:ro
|
- router-images:/backup/volumes/router-images:ro
|
||||||
|
- portainer-data:/backup/volumes/portainer-data:ro
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
@@ -840,6 +834,9 @@ networks:
|
|||||||
name: mike-ai-tools-egress
|
name: mike-ai-tools-egress
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
piper-data:
|
whisper-data:
|
||||||
router-state:
|
router-state:
|
||||||
router-images:
|
router-images:
|
||||||
|
portainer-data:
|
||||||
|
name: portainer_data
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Root-only configuration for the encrypted off-host Restic repository.
|
||||||
|
# Copy to /etc/mike-ai/disaster-backup.env and chmod 600.
|
||||||
|
#
|
||||||
|
# Recommended: mount an Unraid backup share at /mnt/athena-offsite and use:
|
||||||
|
RESTIC_REPOSITORY=/mnt/athena-offsite/restic
|
||||||
|
RESTIC_REQUIRE_MOUNT=/mnt/athena-offsite
|
||||||
|
|
||||||
|
# The password file must ALSO exist outside Athena (password manager/offline
|
||||||
|
# recovery USB). Without it a total-loss backup cannot be decrypted.
|
||||||
|
RESTIC_PASSWORD_FILE=/root/athena-restic-password
|
||||||
|
|
||||||
|
RESTIC_TAG=athena-disaster
|
||||||
|
RESTIC_KEEP_DAILY=14
|
||||||
|
RESTIC_KEEP_WEEKLY=8
|
||||||
|
RESTIC_KEEP_MONTHLY=12
|
||||||
|
|
||||||
|
# Set true only after the repository and credentials have been tested.
|
||||||
|
DISASTER_BACKUP_ENABLED=false
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Mandatory Research and Verification Policy
|
||||||
|
|
||||||
|
When an answer, decision, or planned action depends on external facts and uncertainty could materially affect the result, verify the relevant information before proceeding.
|
||||||
|
|
||||||
|
Never infer the purpose or capabilities of an unfamiliar product, project, repository, application, container, image, service, package, or proper name from its name alone.
|
||||||
|
|
||||||
|
For unfamiliar software or services:
|
||||||
|
|
||||||
|
1. Inspect available local metadata such as the image name, labels, project URL, Compose file, package metadata, or README.
|
||||||
|
2. If its identity or capabilities remain unclear, use an available web, documentation, source-code, or research tool.
|
||||||
|
3. Base the answer on verified information and clearly distinguish facts from inference.
|
||||||
|
|
||||||
|
Research is required when:
|
||||||
|
|
||||||
|
- the information may have changed recently;
|
||||||
|
- you are unfamiliar with an error, parameter, API, feature, path, product, or technical procedure;
|
||||||
|
- compatibility, security, migration, or configuration details are unclear;
|
||||||
|
- your first or second reasonable attempt has failed;
|
||||||
|
- an incorrect assumption could cause damage, data loss, downtime, or significant wasted effort.
|
||||||
|
|
||||||
|
Prefer authoritative primary sources such as official documentation, upstream source code, release notes, specifications, and vendor documentation. Do not invent commands, parameters, endpoints, file paths, capabilities, or configuration options.
|
||||||
|
|
||||||
|
If no suitable research tool is available, clearly state what is uncertain. Ask the user before performing an action that could be harmful or difficult to reverse.
|
||||||
|
|
||||||
|
Do not perform unnecessary research when the answer can be derived reliably from information supplied by the user, local documentation, direct observation, or straightforward reasoning.
|
||||||
|
|
||||||
|
Never include passwords, API keys, tokens, private messages, confidential file contents, or other sensitive information in web searches or requests to external services.
|
||||||
|
|
||||||
|
Research is a verification mechanism, not a substitute for reasoning. Review the sources, reconcile conflicting information, and explain any remaining uncertainty.
|
||||||
+17
-17
@@ -4,7 +4,6 @@
|
|||||||
AI_HOSTNAME=ki-host
|
AI_HOSTNAME=ki-host
|
||||||
ADMIN_USER=mike
|
ADMIN_USER=mike
|
||||||
MODEL_DIR=/data/models
|
MODEL_DIR=/data/models
|
||||||
|
|
||||||
# Installing a new NVIDIA driver can require one reboot. In that case this
|
# Installing a new NVIDIA driver can require one reboot. In that case this
|
||||||
# installer exits with code 20 (NVIDIA) or 21 (stable NIC rename); rerun the
|
# installer exits with code 20 (NVIDIA) or 21 (stable NIC rename); rerun the
|
||||||
# same command after reboot.
|
# same command after reboot.
|
||||||
@@ -13,10 +12,17 @@ INSTALL_NVIDIA_DRIVER=true
|
|||||||
# festlegen (z. B. 610). Leer lassen, um dem aktuellen stabilen Zweig zu folgen.
|
# festlegen (z. B. 610). Leer lassen, um dem aktuellen stabilen Zweig zu folgen.
|
||||||
NVIDIA_DRIVER_BRANCH=
|
NVIDIA_DRIVER_BRANCH=
|
||||||
NVIDIA_MIN_DRIVER_MAJOR=570
|
NVIDIA_MIN_DRIVER_MAJOR=570
|
||||||
TEXT_GPU_DEVICES=0
|
# Stable UUID order: CUDA0 = RTX 5080, CUDA1 = RTX 3060. Positional host
|
||||||
SECONDARY_GPU_DEVICES=1
|
# indices are intentionally avoided because nvidia-smi currently enumerates
|
||||||
IMAGE_GPU_DEVICES=1
|
# the cards in the opposite order.
|
||||||
FLUX_MODEL_DIR=/data/models/FLUX.2-klein-4B
|
TEXT_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
|
||||||
|
SECONDARY_GPU_DEVICES=GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
|
IMAGE_GPU_DEVICES=GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe
|
||||||
|
# FLUX.2 Klein 9B is gated. Accept both BFL model licenses first, then store
|
||||||
|
# the Hugging Face token in this root-readable file (never in this config).
|
||||||
|
HF_TOKEN_FILE=/root/.cache/huggingface/token
|
||||||
|
FLUX_COMPONENT_DIR=/data/models/FLUX.2-klein-9B-components
|
||||||
|
FLUX_TRANSFORMER_DIR=/data/models/FLUX.2-klein-9B-fp8
|
||||||
|
|
||||||
# Headless remote reachability. Firmware power-loss recovery is configured
|
# Headless remote reachability. Firmware power-loss recovery is configured
|
||||||
# separately once at the physical machine.
|
# separately once at the physical machine.
|
||||||
@@ -66,13 +72,10 @@ UNCENSORED_MODEL_SHA256=5d53637a59cfcd3a4d8354e254ffd44943e5a693da2405a3e228c629
|
|||||||
UNCENSORED_PROJECTOR_FILE=qwen3.8-27b-abliterated/mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf
|
UNCENSORED_PROJECTOR_FILE=qwen3.8-27b-abliterated/mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf
|
||||||
UNCENSORED_PROJECTOR_URL=https://huggingface.co/Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF/resolve/main/mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf
|
UNCENSORED_PROJECTOR_URL=https://huggingface.co/Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF/resolve/main/mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf
|
||||||
UNCENSORED_PROJECTOR_SHA256=2284099ce864f1023d721e6ef5eaef32bb56abdbc1dc561c6d91300f12ef2e4b
|
UNCENSORED_PROJECTOR_SHA256=2284099ce864f1023d721e6ef5eaef32bb56abdbc1dc561c6d91300f12ef2e4b
|
||||||
EXPERIMENTAL_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf
|
|
||||||
EXPERIMENTAL_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf
|
|
||||||
EXPERIMENTAL_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199
|
|
||||||
VISION_PROJECTOR_FILE=qwen/mmproj-BF16.gguf
|
VISION_PROJECTOR_FILE=qwen/mmproj-BF16.gguf
|
||||||
VISION_PROJECTOR_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/mmproj-BF16.gguf
|
VISION_PROJECTOR_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/mmproj-BF16.gguf
|
||||||
VISION_PROJECTOR_SHA256=83ee4f4f205fa514161778c41df1ea14144faa0f713510893b63c2395f5c2d53
|
VISION_PROJECTOR_SHA256=83ee4f4f205fa514161778c41df1ea14144faa0f713510893b63c2395f5c2d53
|
||||||
DEFAULT_REASONING_EFFORT=medium
|
DEFAULT_REASONING_EFFORT=off
|
||||||
# All standard profiles use the MTP tensor embedded in their GGUF. A separate
|
# All standard profiles use the MTP tensor embedded in their GGUF. A separate
|
||||||
# draft-model artifact is neither downloaded nor passed to llama-server.
|
# draft-model artifact is neither downloaded nor passed to llama-server.
|
||||||
|
|
||||||
@@ -96,19 +99,16 @@ UNCENSORED_BATCH_SIZE=2048
|
|||||||
UNCENSORED_UBATCH_SIZE=128
|
UNCENSORED_UBATCH_SIZE=128
|
||||||
UNCENSORED_TENSOR_SPLIT=90,10
|
UNCENSORED_TENSOR_SPLIT=90,10
|
||||||
UNCENSORED_MTP_MAX=2
|
UNCENSORED_MTP_MAX=2
|
||||||
EXPERIMENTAL_CONTEXT=76800
|
|
||||||
LLAMA_THREADS=6
|
LLAMA_THREADS=6
|
||||||
LLAMA_THREADS_BATCH=6
|
LLAMA_THREADS_BATCH=6
|
||||||
FAST_PARALLEL_SLOTS=1
|
FAST_PARALLEL_SLOTS=1
|
||||||
LLAMA_CACHE_RAM_MIB=24576
|
LLAMA_CACHE_RAM_MIB=32768
|
||||||
MEDIUM_PARALLEL_SLOTS=1
|
MEDIUM_PARALLEL_SLOTS=1
|
||||||
LARGE_PARALLEL_SLOTS=1
|
LARGE_PARALLEL_SLOTS=1
|
||||||
ULTRA_PARALLEL_SLOTS=1
|
ULTRA_PARALLEL_SLOTS=1
|
||||||
UNCENSORED_PARALLEL_SLOTS=1
|
UNCENSORED_PARALLEL_SLOTS=1
|
||||||
EXPERIMENTAL_PARALLEL_SLOTS=1
|
QWEN3_TTS_IMAGE=ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98
|
||||||
PIPER_TTS_VERSION=1.6.0
|
QWEN3_TTS_CACHE_DIR=/data/models/qwen3-tts-cache
|
||||||
PIPER_VOICE=de_DE-thorsten-high
|
QWEN3_TTS_VOICES_DIR=/data/models/qwen3-tts-voices
|
||||||
XTTS_IMAGE=ghcr.io/coqui-ai/xtts-streaming-server:latest-cuda121@sha256:f7fb3b1f9d4bc88af94da1b5959d8002f1e0b003c97557164034eb8a29f01b90
|
|
||||||
XTTS_CACHE_DIR=/data/models/xtts-v2-cache
|
|
||||||
# Stable UUID of Athena's RTX 3060. Do not use a positional GPU index here.
|
# Stable UUID of Athena's RTX 3060. Do not use a positional GPU index here.
|
||||||
XTTS_GPU_DEVICE=GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
QWEN3_TTS_GPU_DEVICE=GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"id": "medium",
|
"id": "medium",
|
||||||
"alias": "qwen-medium",
|
"alias": "qwen-medium",
|
||||||
"context": 160000,
|
"context": 160000,
|
||||||
"parallel_slots": 2,
|
"parallel_slots": 1,
|
||||||
"model_env": "MEDIUM_MODEL_FILE",
|
"model_env": "MEDIUM_MODEL_FILE",
|
||||||
"model_family": "Qwen3.8-27B IQ4 XS Pure",
|
"model_family": "Qwen3.8-27B IQ4 XS Pure",
|
||||||
"gpu_split": "85:15",
|
"gpu_split": "85:15",
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ def main() -> None:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
"max_tokens": 80,
|
"max_tokens": 500,
|
||||||
|
"reasoning_effort": "none",
|
||||||
"cache_prompt": False,
|
"cache_prompt": False,
|
||||||
}
|
}
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(200, {
|
self._send_json(200, {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"ready": True,
|
"ready": True,
|
||||||
"voices": ["claribel"],
|
"voices": ["alloy"],
|
||||||
"default_voice": "claribel",
|
"default_voice": "alloy",
|
||||||
"load_errors": [],
|
"load_errors": [],
|
||||||
"sample_rate": SAMPLE_RATE,
|
"sample_rate": SAMPLE_RATE,
|
||||||
"uptime_seconds": 1.0,
|
"uptime_seconds": 1.0,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MODULE_PATH = ROOT / "platform/llama-dashboard/app.py"
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status = 202
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return b'{"status":"accepted"}'
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardModeTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"DASHBOARD_HISTORY_DB": str(Path(cls.tempdir.name) / "history.sqlite3"),
|
||||||
|
"DASHBOARD_BACKUP_DIR": str(Path(cls.tempdir.name) / "backups"),
|
||||||
|
"ROUTER_URL": "http://router.test:8081",
|
||||||
|
"ROUTER_API_KEY": "test-key",
|
||||||
|
}):
|
||||||
|
spec = importlib.util.spec_from_file_location("dashboard_app_test", MODULE_PATH)
|
||||||
|
cls.dashboard = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec.loader is not None
|
||||||
|
spec.loader.exec_module(cls.dashboard)
|
||||||
|
cls.backup_dir = Path(cls.tempdir.name) / "backups"
|
||||||
|
cls.backup_dir.mkdir()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.tempdir.cleanup()
|
||||||
|
|
||||||
|
def test_separation_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("separation")
|
||||||
|
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(json.loads(request.data), {"mode": "separation"})
|
||||||
|
self.assertEqual(request.get_header("Authorization"), "Bearer test-key")
|
||||||
|
|
||||||
|
def test_voice_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("voice")
|
||||||
|
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(json.loads(request.data), {"mode": "voice"})
|
||||||
|
|
||||||
|
def test_voice_change_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("voicechange")
|
||||||
|
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(json.loads(request.data), {"mode": "voicechange"})
|
||||||
|
|
||||||
|
def test_applio_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("applio")
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
self.assertEqual(json.loads(urlopen.call_args.args[0].data), {"mode": "applio"})
|
||||||
|
|
||||||
|
def test_yue2_mode_is_forwarded_to_router(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen", return_value=_Response()) as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("yue2")
|
||||||
|
self.assertEqual(status, 202)
|
||||||
|
self.assertEqual(body, {"status": "accepted"})
|
||||||
|
self.assertEqual(json.loads(urlopen.call_args.args[0].data), {"mode": "yue2"})
|
||||||
|
|
||||||
|
def test_unknown_mode_is_rejected_without_router_request(self):
|
||||||
|
with patch.object(self.dashboard.urllib.request, "urlopen") as urlopen:
|
||||||
|
status, body = self.dashboard.change_mode("unknown")
|
||||||
|
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertEqual(body, {"error": "invalid mode"})
|
||||||
|
urlopen.assert_not_called()
|
||||||
|
|
||||||
|
def test_dashboard_uses_one_status_poll_for_all_mode_labels(self):
|
||||||
|
html = self.dashboard.HTML
|
||||||
|
|
||||||
|
self.assertEqual(html.count("fetch('/api/status'"), 1)
|
||||||
|
self.assertNotIn("refreshVoiceChange", html)
|
||||||
|
self.assertIn("voicechange:'X-VC Voice Changer'", html)
|
||||||
|
self.assertIn("applio:'Applio / RVC'", html)
|
||||||
|
|
||||||
|
def test_dashboard_offers_both_applio_frontends(self):
|
||||||
|
html = self.dashboard.HTML
|
||||||
|
|
||||||
|
self.assertIn("Original Applio UI", html)
|
||||||
|
self.assertIn("Mikes Applio UI", html)
|
||||||
|
self.assertIn("http://192.168.1.212:8011/", html)
|
||||||
|
self.assertIn("http://192.168.1.212:8012/", html)
|
||||||
|
|
||||||
|
def test_dashboard_keeps_ace_step_and_offers_yue2_separately(self):
|
||||||
|
html = self.dashboard.HTML
|
||||||
|
|
||||||
|
self.assertIn("ACE-Step Studio", html)
|
||||||
|
self.assertIn("YuE2 Studio", html)
|
||||||
|
self.assertIn("setMode('music')", html)
|
||||||
|
self.assertIn("setMode('yue2')", html)
|
||||||
|
self.assertIn("http://192.168.1.212:8014/", html)
|
||||||
|
|
||||||
|
def test_dashboard_lists_only_portable_encrypted_backups(self):
|
||||||
|
valid = self.backup_dir / "athena-portable-2026-09-10T10-00-00Z.tar.zst.age"
|
||||||
|
valid.write_bytes(b"encrypted")
|
||||||
|
valid.with_name(valid.name + ".sha256").write_text(
|
||||||
|
"a" * 64 + " " + valid.name + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(self.backup_dir / "unrelated.txt").write_text("ignore", encoding="utf-8")
|
||||||
|
|
||||||
|
backups = self.dashboard.backup_inventory()
|
||||||
|
|
||||||
|
self.assertEqual([item["name"] for item in backups], [valid.name])
|
||||||
|
self.assertEqual(backups[0]["sha256"], "a" * 64)
|
||||||
|
self.assertTrue(backups[0]["download_url"].startswith("/api/backups/download/"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+13
-13
@@ -561,14 +561,14 @@ d=json.load(sys.stdin)
|
|||||||
tts=d["tts"]
|
tts=d["tts"]
|
||||||
assert tts["reachable"] is True, tts
|
assert tts["reachable"] is True, tts
|
||||||
assert tts["ready"] is True, tts
|
assert tts["ready"] is True, tts
|
||||||
assert set(tts["voices"])=={"claribel"}, tts
|
assert set(tts["voices"])=={"alloy"}, tts
|
||||||
' && ok "Status: TTS erreichbar, bereit, 2 Stimmen" || bad "Status tts-Section"
|
' && ok "Status: TTS erreichbar und bereit" || bad "Status tts-Section"
|
||||||
|
|
||||||
# --- 28. TTS: POST /v1/audio/speech (wav) ---------------------------------------------------------------
|
# --- 28. TTS: POST /v1/audio/speech (wav) ---------------------------------------------------------------
|
||||||
echo "== Test 28: POST /v1/audio/speech (wav)"
|
echo "== Test 28: POST /v1/audio/speech (wav)"
|
||||||
CODE=$(curl -s -o /tmp/tts28.wav -w "%{http_code}" -D /tmp/hdr28.txt \
|
CODE=$(curl -s -o /tmp/tts28.wav -w "%{http_code}" -D /tmp/hdr28.txt \
|
||||||
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
||||||
-d '{"model":"xtts-v2","input":"Hallo Welt","voice":"claribel","response_format":"wav"}')
|
-d '{"model":"qwen3-tts","input":"Hallo Welt","voice":"alloy","response_format":"wav"}')
|
||||||
CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
|
CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
|
||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts28.wav ] && echo "$CTYPE" | grep -qi "audio/wav" \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts28.wav ] && echo "$CTYPE" | grep -qi "audio/wav" \
|
||||||
&& ok "TTS wav (200, $CTYPE, $(stat -f%z /tmp/tts28.wav 2>/dev/null || stat -c%s /tmp/tts28.wav) Bytes)" \
|
&& ok "TTS wav (200, $CTYPE, $(stat -f%z /tmp/tts28.wav 2>/dev/null || stat -c%s /tmp/tts28.wav) Bytes)" \
|
||||||
@@ -578,7 +578,7 @@ CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
|
|||||||
echo "== Test 29: POST /v1/audio/speech (mp3, Default)"
|
echo "== Test 29: POST /v1/audio/speech (mp3, Default)"
|
||||||
CODE=$(curl -s -o /tmp/tts29.mp3 -w "%{http_code}" -D /tmp/hdr29.txt \
|
CODE=$(curl -s -o /tmp/tts29.mp3 -w "%{http_code}" -D /tmp/hdr29.txt \
|
||||||
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
||||||
-d '{"input":"Guten Tag","voice":"claribel"}')
|
-d '{"input":"Guten Tag","voice":"alloy"}')
|
||||||
CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
|
CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
|
||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts29.mp3 ] && echo "$CTYPE" | grep -qi "audio/mpeg" \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts29.mp3 ] && echo "$CTYPE" | grep -qi "audio/mpeg" \
|
||||||
&& ok "TTS mp3 (200, $CTYPE)" || bad "TTS mp3 (Code $CODE, $CTYPE)"
|
&& ok "TTS mp3 (200, $CTYPE)" || bad "TTS mp3 (Code $CODE, $CTYPE)"
|
||||||
@@ -586,7 +586,7 @@ CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
|
|||||||
# --- 30. TTS: Validierung --------------------------------------------------------------------------------
|
# --- 30. TTS: Validierung --------------------------------------------------------------------------------
|
||||||
echo "== Test 30: TTS-Validierung"
|
echo "== Test 30: TTS-Validierung"
|
||||||
CODE=$(curl -s -o /tmp/err30a.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
CODE=$(curl -s -o /tmp/err30a.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
||||||
-H "Content-Type: application/json" -d '{"voice":"claribel"}')
|
-H "Content-Type: application/json" -d '{"voice":"alloy"}')
|
||||||
cat /tmp/err30a.json; echo
|
cat /tmp/err30a.json; echo
|
||||||
[ "$CODE" = "400" ] && ok "400 bei fehlendem input" || bad "erwartet 400, bekam $CODE"
|
[ "$CODE" = "400" ] && ok "400 bei fehlendem input" || bad "erwartet 400, bekam $CODE"
|
||||||
|
|
||||||
@@ -608,7 +608,7 @@ cat /tmp/err30d.json; echo
|
|||||||
# --- 31. TTS: Worker-Fehler → 503 ------------------------------------------------------------------------
|
# --- 31. TTS: Worker-Fehler → 503 ------------------------------------------------------------------------
|
||||||
echo "== Test 31: TTS-Worker-Fehler → 503"
|
echo "== Test 31: TTS-Worker-Fehler → 503"
|
||||||
CODE=$(curl -s -o /tmp/err31.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
CODE=$(curl -s -o /tmp/err31.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
||||||
-H "Content-Type: application/json" -d '{"input":"FAIL","voice":"claribel"}')
|
-H "Content-Type: application/json" -d '{"input":"FAIL","voice":"alloy"}')
|
||||||
cat /tmp/err31.json; echo
|
cat /tmp/err31.json; echo
|
||||||
[ "$CODE" = "503" ] && ok "503 bei TTS-Worker-Fehler" || bad "erwartet 503, bekam $CODE"
|
[ "$CODE" = "503" ] && ok "503 bei TTS-Worker-Fehler" || bad "erwartet 503, bekam $CODE"
|
||||||
|
|
||||||
@@ -617,7 +617,7 @@ echo "== Test 32: TTS-Worker down → 503"
|
|||||||
kill "$TTS_PID" 2>/dev/null || true
|
kill "$TTS_PID" 2>/dev/null || true
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
CODE=$(curl -s -o /tmp/err32.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
CODE=$(curl -s -o /tmp/err32.json -w "%{http_code}" "$BASE/v1/audio/speech" \
|
||||||
-H "Content-Type: application/json" -d '{"input":"Hallo","voice":"claribel"}')
|
-H "Content-Type: application/json" -d '{"input":"Hallo","voice":"alloy"}')
|
||||||
cat /tmp/err32.json; echo
|
cat /tmp/err32.json; echo
|
||||||
[ "$CODE" = "503" ] && ok "503 bei downem TTS-Worker" || bad "erwartet 503, bekam $CODE"
|
[ "$CODE" = "503" ] && ok "503 bei downem TTS-Worker" || bad "erwartet 503, bekam $CODE"
|
||||||
RESP=$(curl -sf "$BASE/status")
|
RESP=$(curl -sf "$BASE/status")
|
||||||
@@ -634,7 +634,7 @@ MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \
|
|||||||
TTS_PID=$!
|
TTS_PID=$!
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
CODE=$(curl -s -o /tmp/tts33.wav -w "%{http_code}" "$BASE/v1/audio/speech" \
|
CODE=$(curl -s -o /tmp/tts33.wav -w "%{http_code}" "$BASE/v1/audio/speech" \
|
||||||
-H "Content-Type: application/json" -d '{"input":"Wieder da","voice":"claribel","response_format":"wav"}')
|
-H "Content-Type: application/json" -d '{"input":"Wieder da","voice":"alloy","response_format":"wav"}')
|
||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \
|
||||||
&& ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)"
|
&& ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)"
|
||||||
|
|
||||||
@@ -727,8 +727,8 @@ import json,sys
|
|||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
ids={m["id"] for m in d["data"]}
|
ids={m["id"] for m in d["data"]}
|
||||||
assert "whisper-1" in ids, ids
|
assert "whisper-1" in ids, ids
|
||||||
assert "xtts-v2" in ids, ids
|
assert "qwen3-tts" in ids, ids
|
||||||
' && ok "Audio-Modelle: whisper-1 + xtts-v2" || bad "Audio-Modelle"
|
' && ok "Audio-Modelle: whisper-1 + qwen3-tts" || bad "Audio-Modelle"
|
||||||
|
|
||||||
# --- 41. /v1/audio/voices ------------------------------------------------------------------------------------------
|
# --- 41. /v1/audio/voices ------------------------------------------------------------------------------------------
|
||||||
echo "== Test 41: GET /v1/audio/voices"
|
echo "== Test 41: GET /v1/audio/voices"
|
||||||
@@ -738,8 +738,8 @@ echo "$RESP" | python3 -c '
|
|||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
ids={v["id"] for v in d["data"]}
|
ids={v["id"] for v in d["data"]}
|
||||||
assert "claribel" in ids, ids
|
assert "alloy" in ids, ids
|
||||||
' && ok "Audio-Voices: claribel" || bad "Audio-Voices"
|
' && ok "Audio-Voices: alloy" || bad "Audio-Voices"
|
||||||
|
|
||||||
# --- 42. STT + Qwen parallel ----------------------------------------------------------------------------------------
|
# --- 42. STT + Qwen parallel ----------------------------------------------------------------------------------------
|
||||||
echo "== Test 42: STT + Qwen parallel"
|
echo "== Test 42: STT + Qwen parallel"
|
||||||
@@ -770,7 +770,7 @@ sleep 0.2
|
|||||||
# TTS-Request
|
# TTS-Request
|
||||||
CODE=$(curl -s -o /tmp/tts43.mp3 -w "%{http_code}" \
|
CODE=$(curl -s -o /tmp/tts43.mp3 -w "%{http_code}" \
|
||||||
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
|
||||||
-d '{"input":"Hallo","voice":"claribel"}')
|
-d '{"input":"Hallo","voice":"alloy"}')
|
||||||
wait $STT_PID43
|
wait $STT_PID43
|
||||||
[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \
|
||||||
&& ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)"
|
&& ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)"
|
||||||
|
|||||||
@@ -26,7 +26,185 @@ def image_item(state="exited"):
|
|||||||
"Labels": {controller.IMAGE_LABEL_KEY: controller.IMAGE_WORKER}}
|
"Labels": {controller.IMAGE_LABEL_KEY: controller.IMAGE_WORKER}}
|
||||||
|
|
||||||
|
|
||||||
|
def restore_item(state="exited"):
|
||||||
|
return {"Id": "id-restore", "State": state,
|
||||||
|
"Labels": {controller.IMAGE_LABEL_KEY: controller.RESTORE_WORKER}}
|
||||||
|
|
||||||
|
|
||||||
|
def tts_item(state="running"):
|
||||||
|
return {"Id": "id-tts", "State": state,
|
||||||
|
"Labels": {controller.TTS_LABEL_KEY: controller.TTS_WORKER}}
|
||||||
|
|
||||||
|
|
||||||
|
def music_item(state="exited"):
|
||||||
|
return {"Id": "id-music", "State": state,
|
||||||
|
"Labels": {controller.MUSIC_LABEL_KEY: "acestep"}}
|
||||||
|
|
||||||
|
|
||||||
|
def yue2_item(state="exited"):
|
||||||
|
return {"Id": "id-yue2", "State": state,
|
||||||
|
"Labels": {controller.MUSIC_LABEL_KEY: "yue2"}}
|
||||||
|
|
||||||
|
|
||||||
|
def separator_item(state="exited"):
|
||||||
|
return {"Id": "id-separator", "State": state,
|
||||||
|
"Labels": {controller.SEPARATOR_LABEL_KEY: "bs-roformer"}}
|
||||||
|
|
||||||
|
|
||||||
|
def voice_item(state="exited"):
|
||||||
|
return {"Id": "id-voice", "State": state,
|
||||||
|
"Labels": {controller.VOICE_LABEL_KEY: "vevo2"}}
|
||||||
|
|
||||||
|
|
||||||
|
def voice_change_item(state="exited"):
|
||||||
|
return {"Id": "id-xvc", "State": state,
|
||||||
|
"Labels": {controller.VOICE_CHANGE_LABEL_KEY: "xvc"}}
|
||||||
|
|
||||||
|
|
||||||
class ProfileControllerTests(unittest.TestCase):
|
class ProfileControllerTests(unittest.TestCase):
|
||||||
|
def test_yue2_start_exclusively_stops_llm_and_ace_step(self):
|
||||||
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
|
profiles["ultra"] = item("ultra", "running")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(method, path):
|
||||||
|
calls.append((method, path))
|
||||||
|
return 204, b""
|
||||||
|
|
||||||
|
with patch.object(controller, "YUE2_WORKER", "yue2"), \
|
||||||
|
patch.object(controller, "MUSIC_WORKER", "acestep"), \
|
||||||
|
patch.object(controller, "containers", return_value=profiles), \
|
||||||
|
patch.object(controller, "yue2_container", return_value=yue2_item()), \
|
||||||
|
patch.object(controller, "music_container", return_value=music_item("running")), \
|
||||||
|
patch.object(controller, "image_containers", return_value=[image_item("running")]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
|
result = controller.set_yue2_worker(True)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"yue2_worker": "yue2", "state": "running"})
|
||||||
|
self.assertIn(("POST", "/containers/id-ultra/stop?t=120"), calls)
|
||||||
|
self.assertIn(("POST", "/containers/id-music/stop?t=30"), calls)
|
||||||
|
self.assertEqual(calls[-1], ("POST", "/containers/id-yue2/start"))
|
||||||
|
|
||||||
|
def test_voice_change_start_exclusively_stops_gpu_workers(self):
|
||||||
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
|
profiles["medium"] = item("medium", "running")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(method, path):
|
||||||
|
calls.append((method, path))
|
||||||
|
return 204, b""
|
||||||
|
|
||||||
|
with patch.object(controller, "VOICE_CHANGE_WORKER", "xvc"), \
|
||||||
|
patch.object(controller, "VOICE_WORKER", "vevo2"), \
|
||||||
|
patch.object(controller, "MUSIC_WORKER", "acestep"), \
|
||||||
|
patch.object(controller, "SEPARATOR_WORKER", "bs-roformer"), \
|
||||||
|
patch.object(controller, "containers", return_value=profiles), \
|
||||||
|
patch.object(controller, "voice_change_container", return_value=voice_change_item()), \
|
||||||
|
patch.object(controller, "voice_container", return_value=voice_item("running")), \
|
||||||
|
patch.object(controller, "music_container", return_value=music_item("running")), \
|
||||||
|
patch.object(controller, "separator_container", return_value=separator_item("running")), \
|
||||||
|
patch.object(controller, "image_containers", return_value=[image_item("running")]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
|
result = controller.set_voice_change_worker(True)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"voice_change_worker": "xvc", "state": "running"})
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
("POST", "/containers/id-medium/stop?t=120"),
|
||||||
|
("POST", "/containers/id-flux/stop?t=20"),
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
|
("POST", "/containers/id-music/stop?t=30"),
|
||||||
|
("POST", "/containers/id-separator/stop?t=30"),
|
||||||
|
("POST", "/containers/id-voice/stop?t=30"),
|
||||||
|
("POST", "/containers/id-xvc/start"),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_voice_start_exclusively_stops_gpu_workers(self):
|
||||||
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
|
profiles["medium"] = item("medium", "running")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(method, path):
|
||||||
|
calls.append((method, path))
|
||||||
|
return 204, b""
|
||||||
|
|
||||||
|
with patch.object(controller, "VOICE_WORKER", "vevo2"), \
|
||||||
|
patch.object(controller, "MUSIC_WORKER", "acestep"), \
|
||||||
|
patch.object(controller, "SEPARATOR_WORKER", "bs-roformer"), \
|
||||||
|
patch.object(controller, "containers", return_value=profiles), \
|
||||||
|
patch.object(controller, "voice_container", return_value=voice_item()), \
|
||||||
|
patch.object(controller, "music_container", return_value=music_item("running")), \
|
||||||
|
patch.object(controller, "separator_container", return_value=separator_item("running")), \
|
||||||
|
patch.object(controller, "image_containers", return_value=[image_item("running")]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
|
result = controller.set_voice_worker(True)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"voice_worker": "vevo2", "state": "running"})
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
("POST", "/containers/id-medium/stop?t=120"),
|
||||||
|
("POST", "/containers/id-flux/stop?t=20"),
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
|
("POST", "/containers/id-music/stop?t=30"),
|
||||||
|
("POST", "/containers/id-separator/stop?t=30"),
|
||||||
|
("POST", "/containers/id-voice/start"),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_separator_start_exclusively_stops_gpu_workers(self):
|
||||||
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
|
profiles["large"] = item("large", "running")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(method, path):
|
||||||
|
calls.append((method, path))
|
||||||
|
return 204, b""
|
||||||
|
|
||||||
|
with patch.object(controller, "SEPARATOR_WORKER", "bs-roformer"), \
|
||||||
|
patch.object(controller, "MUSIC_WORKER", "acestep"), \
|
||||||
|
patch.object(controller, "containers", return_value=profiles), \
|
||||||
|
patch.object(controller, "separator_container", return_value=separator_item()), \
|
||||||
|
patch.object(controller, "music_container", return_value=music_item("running")), \
|
||||||
|
patch.object(controller, "image_containers", return_value=[image_item("running")]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
|
result = controller.set_separator_worker(True)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"separator_worker": "bs-roformer", "state": "running"})
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
("POST", "/containers/id-large/stop?t=120"),
|
||||||
|
("POST", "/containers/id-flux/stop?t=20"),
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
|
("POST", "/containers/id-music/stop?t=30"),
|
||||||
|
("POST", "/containers/id-separator/start"),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_music_start_exclusively_stops_gpu_workers(self):
|
||||||
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
|
profiles["ultra"] = item("ultra", "running")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(method, path):
|
||||||
|
calls.append((method, path))
|
||||||
|
return 204, b""
|
||||||
|
|
||||||
|
with patch.object(controller, "MUSIC_WORKER", "acestep"), \
|
||||||
|
patch.object(controller, "containers", return_value=profiles), \
|
||||||
|
patch.object(controller, "music_container", return_value=music_item()), \
|
||||||
|
patch.object(controller, "image_containers",
|
||||||
|
return_value=[image_item("running")]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
|
result = controller.set_music_worker(True)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"music_worker": "acestep", "state": "running"})
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
("POST", "/containers/id-ultra/stop?t=120"),
|
||||||
|
("POST", "/containers/id-flux/stop?t=20"),
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
|
("POST", "/containers/id-music/start"),
|
||||||
|
])
|
||||||
|
|
||||||
def test_rejects_unknown_profile_before_docker_call(self):
|
def test_rejects_unknown_profile_before_docker_call(self):
|
||||||
with patch.object(controller, "docker_request") as request:
|
with patch.object(controller, "docker_request") as request:
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -43,7 +221,8 @@ class ProfileControllerTests(unittest.TestCase):
|
|||||||
return 204, b""
|
return 204, b""
|
||||||
|
|
||||||
with patch.object(controller, "containers", return_value=profiles), \
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
patch.object(controller, "image_container", return_value=image_item()), \
|
patch.object(controller, "image_containers", return_value=[image_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
patch.object(controller, "docker_request", side_effect=request):
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
result = controller.activate("medium")
|
result = controller.activate("medium")
|
||||||
|
|
||||||
@@ -56,7 +235,8 @@ class ProfileControllerTests(unittest.TestCase):
|
|||||||
def test_fails_if_profile_container_is_missing(self):
|
def test_fails_if_profile_container_is_missing(self):
|
||||||
profiles = {name: item(name) for name in controller.ALLOWED[:-1]}
|
profiles = {name: item(name) for name in controller.ALLOWED[:-1]}
|
||||||
with patch.object(controller, "containers", return_value=profiles), \
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
patch.object(controller, "image_container", return_value=image_item()):
|
patch.object(controller, "image_containers", return_value=[image_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()):
|
||||||
with self.assertRaisesRegex(RuntimeError, "missing"):
|
with self.assertRaisesRegex(RuntimeError, "missing"):
|
||||||
controller.activate("fast")
|
controller.activate("fast")
|
||||||
|
|
||||||
@@ -71,13 +251,38 @@ class ProfileControllerTests(unittest.TestCase):
|
|||||||
|
|
||||||
with patch.object(controller, "containers", return_value=profiles), \
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
patch.object(controller, "image_container", return_value=image_item()), \
|
patch.object(controller, "image_container", return_value=image_item()), \
|
||||||
|
patch.object(controller, "image_containers",
|
||||||
|
return_value=[image_item(), restore_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
patch.object(controller, "docker_request", side_effect=request):
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
controller.set_image_worker(True)
|
controller.set_image_worker(True)
|
||||||
self.assertEqual(calls, [
|
self.assertEqual(calls, [
|
||||||
("POST", "/containers/id-medium/stop?t=120"),
|
("POST", "/containers/id-medium/stop?t=120"),
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
("POST", "/containers/id-flux/start"),
|
("POST", "/containers/id-flux/start"),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def test_restore_start_stops_flux_and_starts_restore(self):
|
||||||
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request(method, path):
|
||||||
|
calls.append((method, path))
|
||||||
|
return 204, b""
|
||||||
|
|
||||||
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
|
patch.object(controller, "image_container", return_value=restore_item()), \
|
||||||
|
patch.object(controller, "image_containers",
|
||||||
|
return_value=[image_item("running"), restore_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
|
controller.set_image_worker(True, controller.RESTORE_WORKER)
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
("POST", "/containers/id-tts/stop?t=30"),
|
||||||
|
("POST", "/containers/id-flux/stop?t=20"),
|
||||||
|
("POST", "/containers/id-restore/start"),
|
||||||
|
])
|
||||||
|
|
||||||
def test_profile_activation_stops_image_worker_first(self):
|
def test_profile_activation_stops_image_worker_first(self):
|
||||||
profiles = {name: item(name) for name in controller.ALLOWED}
|
profiles = {name: item(name) for name in controller.ALLOWED}
|
||||||
calls = []
|
calls = []
|
||||||
@@ -87,8 +292,9 @@ class ProfileControllerTests(unittest.TestCase):
|
|||||||
return 204, b""
|
return 204, b""
|
||||||
|
|
||||||
with patch.object(controller, "containers", return_value=profiles), \
|
with patch.object(controller, "containers", return_value=profiles), \
|
||||||
patch.object(controller, "image_container",
|
patch.object(controller, "image_containers",
|
||||||
return_value=image_item("running")), \
|
return_value=[image_item("running"), restore_item()]), \
|
||||||
|
patch.object(controller, "tts_container", return_value=tts_item()), \
|
||||||
patch.object(controller, "docker_request", side_effect=request):
|
patch.object(controller, "docker_request", side_effect=request):
|
||||||
controller.activate("fast")
|
controller.activate("fast")
|
||||||
self.assertEqual(calls, [
|
self.assertEqual(calls, [
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import io
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryScriptTests(unittest.TestCase):
|
||||||
|
def test_latest_alias_points_to_production_backup(self) -> None:
|
||||||
|
script = (ROOT / "restore.sh").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("if [[ $ARCHIVE == latest ]]", script)
|
||||||
|
self.assertIn("ARCHIVE=/data/docker-backups/athena-latest.tar.gz", script)
|
||||||
|
|
||||||
|
def _archive(self, members: list[str]) -> Path:
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
|
||||||
|
tmp.close()
|
||||||
|
archive = Path(tmp.name)
|
||||||
|
with tarfile.open(archive, "w:gz") as handle:
|
||||||
|
for name in members:
|
||||||
|
payload = b"test\n"
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
info.size = len(payload)
|
||||||
|
handle.addfile(info, io.BytesIO(payload))
|
||||||
|
self.addCleanup(archive.unlink, missing_ok=True)
|
||||||
|
return archive
|
||||||
|
|
||||||
|
def test_current_backup_layout_is_accepted(self) -> None:
|
||||||
|
archive = self._archive([
|
||||||
|
"/backup/etc-mike-ai/stack.env",
|
||||||
|
"/backup/volumes/router-state/state.json",
|
||||||
|
])
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(ROOT / "restore.sh"), "--check", str(archive)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("ATHENA_BACKUP_CHECK_OK", result.stdout)
|
||||||
|
|
||||||
|
def test_unexpected_tree_is_rejected(self) -> None:
|
||||||
|
archive = self._archive([
|
||||||
|
"/backup/etc-mike-ai/stack.env",
|
||||||
|
"/backup/volumes/router-state/state.json",
|
||||||
|
"/etc/shadow",
|
||||||
|
])
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(ROOT / "restore.sh"), "--check", str(archive)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("Unerwarteter Pfad", result.stderr)
|
||||||
|
|
||||||
|
def test_backup_prefix_extracts_to_restore_layout(self) -> None:
|
||||||
|
archive = self._archive([
|
||||||
|
"/backup/etc-mike-ai/stack.env",
|
||||||
|
"/backup/volumes/router-state/state.json",
|
||||||
|
])
|
||||||
|
with tempfile.TemporaryDirectory() as target:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"tar", "-xzf", str(archive), "-C", target,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
root = Path(target, "backup")
|
||||||
|
self.assertTrue(Path(root, "etc-mike-ai", "stack.env").is_file())
|
||||||
|
self.assertTrue(Path(root, "volumes", "router-state", "state.json").is_file())
|
||||||
|
|
||||||
|
def test_installer_uses_current_profiles(self) -> None:
|
||||||
|
installer = (ROOT / "install.sh").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("llama-experimental", installer)
|
||||||
|
for profile in ("fast", "medium", "large", "ultra", "uncensored"):
|
||||||
|
self.assertIn(f"llama-{profile}", installer)
|
||||||
|
|
||||||
|
def test_gateway_consumers_do_not_require_rebinding(self) -> None:
|
||||||
|
manager = (ROOT / "manage.sh").read_text(encoding="utf-8")
|
||||||
|
installer = (ROOT / "install.sh").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn('stop llama-dashboard portainer', manager)
|
||||||
|
self.assertNotIn('stop llama-dashboard portainer', installer)
|
||||||
|
self.assertNotIn('force-recreate llama-dashboard portainer', manager)
|
||||||
|
|
||||||
|
def test_gateway_proxies_stable_ui_services_and_portainer_backup(self) -> None:
|
||||||
|
compose = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||||
|
gateway = (ROOT / "platform/docker/wireguard-gateway/entrypoint.sh").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertNotIn('network_mode: "service:wireguard-gateway"', compose)
|
||||||
|
self.assertNotIn('"8099:8099"', compose)
|
||||||
|
self.assertNotIn('"9443:9443"', compose)
|
||||||
|
self.assertIn('start_proxy 8099 llama-dashboard:8099', gateway)
|
||||||
|
self.assertIn('start_proxy 9443 portainer:9443', gateway)
|
||||||
|
self.assertIn('portainer-data:/backup/volumes/portainer-data:ro', compose)
|
||||||
|
|
||||||
|
def test_disaster_recovery_covers_all_three_scenarios_without_formatting(self) -> None:
|
||||||
|
script = (ROOT / "disaster-recovery.sh").read_text(encoding="utf-8")
|
||||||
|
for scenario in ("system", "data", "all"):
|
||||||
|
self.assertIn(scenario, script)
|
||||||
|
self.assertIn("mountpoint -q /data", script)
|
||||||
|
self.assertIn("--portable", script)
|
||||||
|
for destructive in ("mkfs", "fdisk", "parted", "reboot", "shutdown"):
|
||||||
|
self.assertNotIn(f"{destructive} ", script)
|
||||||
|
|
||||||
|
def test_backup_layers_include_code_and_irreplaceable_data(self) -> None:
|
||||||
|
compose = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||||
|
export = (ROOT / "platform/backup/athena-export-backup").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertIn("/opt/mike-ai:/backup/opt-mike-ai:ro", compose)
|
||||||
|
self.assertIn("/data/voice/applio/logs", export)
|
||||||
|
self.assertIn("/data/voice/applio/datasets", export)
|
||||||
|
self.assertIn("ATHENA_EXPORT_KEEP:-5", export)
|
||||||
|
self.assertNotIn("add_path /data/models", export)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+90
-16
@@ -24,12 +24,15 @@ from router_support import ( # noqa: E402
|
|||||||
load_profile_registry,
|
load_profile_registry,
|
||||||
)
|
)
|
||||||
from ai_profile_router import ( # noqa: E402
|
from ai_profile_router import ( # noqa: E402
|
||||||
|
STATE,
|
||||||
_cap_chat_generation,
|
_cap_chat_generation,
|
||||||
_context_matches,
|
_context_matches,
|
||||||
|
_inject_global_system_policy,
|
||||||
_normalize_chat_image,
|
_normalize_chat_image,
|
||||||
_normalize_chat_images,
|
_normalize_chat_images,
|
||||||
_normalize_llamacpp_reasoning,
|
_normalize_llamacpp_reasoning,
|
||||||
_request_has_image,
|
_request_has_image,
|
||||||
|
switch_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +96,16 @@ class ProfileRegistryTests(unittest.TestCase):
|
|||||||
self.assertFalse(_context_matches(80000, 76800))
|
self.assertFalse(_context_matches(80000, 76800))
|
||||||
self.assertFalse(_context_matches(80000, 82000))
|
self.assertFalse(_context_matches(80000, 82000))
|
||||||
|
|
||||||
|
def test_ready_profile_clears_stale_unavailable_flag(self) -> None:
|
||||||
|
with STATE.avail_lock:
|
||||||
|
STATE.qwen_unavailable = True
|
||||||
|
status = {"reachable": True, "model": "qwen-medium", "ctx": 160000}
|
||||||
|
with patch("ai_profile_router.current_profile", return_value="medium"), \
|
||||||
|
patch("ai_profile_router.upstream_status", return_value=status):
|
||||||
|
switch_profile("medium")
|
||||||
|
with STATE.avail_lock:
|
||||||
|
self.assertFalse(STATE.qwen_unavailable)
|
||||||
|
|
||||||
|
|
||||||
class ChatImageInputTests(unittest.TestCase):
|
class ChatImageInputTests(unittest.TestCase):
|
||||||
def test_small_png_data_url_is_accepted(self) -> None:
|
def test_small_png_data_url_is_accepted(self) -> None:
|
||||||
@@ -129,34 +142,37 @@ class ChatImageInputTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class LlamaCppReasoningTests(unittest.TestCase):
|
class LlamaCppReasoningTests(unittest.TestCase):
|
||||||
def test_none_really_disables_thinking(self) -> None:
|
def test_disabled_values_really_disable_thinking(self) -> None:
|
||||||
request = {"reasoning_effort": "none", "messages": []}
|
for effort in (None, "none", "off", "disabled", False):
|
||||||
|
with self.subTest(effort=effort):
|
||||||
|
request = {"reasoning_effort": effort, "messages": []}
|
||||||
normalized = _normalize_llamacpp_reasoning(request)
|
normalized = _normalize_llamacpp_reasoning(request)
|
||||||
self.assertNotIn("reasoning_effort", normalized)
|
self.assertNotIn("reasoning_effort", normalized)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
normalized["chat_template_kwargs"],
|
normalized["chat_template_kwargs"],
|
||||||
{"enable_thinking": False},
|
{"enable_thinking": False},
|
||||||
)
|
)
|
||||||
|
self.assertEqual(normalized["thinking_budget_tokens"], 0)
|
||||||
|
|
||||||
def test_low_and_medium_reach_chat_template(self) -> None:
|
def test_reasoning_levels_receive_real_per_request_budgets(self) -> None:
|
||||||
for effort in ("low", "medium"):
|
expected = {
|
||||||
|
"minimal": ("low", 256),
|
||||||
|
"low": ("low", 768),
|
||||||
|
"medium": ("medium", 2048),
|
||||||
|
"high": ("xhigh", 4096),
|
||||||
|
"xhigh": ("xhigh", 8192),
|
||||||
|
"max": ("xhigh", 8192),
|
||||||
|
"ultra": ("xhigh", 8192),
|
||||||
|
}
|
||||||
|
for effort, (template_effort, budget) in expected.items():
|
||||||
with self.subTest(effort=effort):
|
with self.subTest(effort=effort):
|
||||||
request = {"reasoning_effort": effort, "messages": []}
|
request = {"reasoning_effort": effort, "messages": []}
|
||||||
normalized = _normalize_llamacpp_reasoning(request)
|
normalized = _normalize_llamacpp_reasoning(request)
|
||||||
self.assertEqual(normalized["chat_template_kwargs"], {
|
self.assertEqual(normalized["chat_template_kwargs"], {
|
||||||
"enable_thinking": True,
|
"enable_thinking": True,
|
||||||
"reasoning_effort": effort,
|
"reasoning_effort": template_effort,
|
||||||
})
|
})
|
||||||
|
self.assertEqual(normalized["thinking_budget_tokens"], budget)
|
||||||
def test_unsupported_high_levels_are_clamped_to_xhigh(self) -> None:
|
|
||||||
for effort in ("high", "xhigh", "max", "ultra"):
|
|
||||||
with self.subTest(effort=effort):
|
|
||||||
request = {"reasoning_effort": effort, "messages": []}
|
|
||||||
normalized = _normalize_llamacpp_reasoning(request)
|
|
||||||
self.assertEqual(
|
|
||||||
normalized["chat_template_kwargs"]["reasoning_effort"],
|
|
||||||
"xhigh",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_existing_template_kwargs_are_preserved(self) -> None:
|
def test_existing_template_kwargs_are_preserved(self) -> None:
|
||||||
request = {
|
request = {
|
||||||
@@ -170,10 +186,21 @@ class LlamaCppReasoningTests(unittest.TestCase):
|
|||||||
"enable_thinking": True,
|
"enable_thinking": True,
|
||||||
"reasoning_effort": "low",
|
"reasoning_effort": "low",
|
||||||
})
|
})
|
||||||
|
self.assertEqual(normalized["thinking_budget_tokens"], 768)
|
||||||
|
|
||||||
def test_request_without_effort_is_unchanged(self) -> None:
|
def test_request_without_effort_uses_safe_off_default(self) -> None:
|
||||||
request = {"messages": []}
|
request = {"messages": []}
|
||||||
self.assertIs(_normalize_llamacpp_reasoning(request), request)
|
self.assertIs(_normalize_llamacpp_reasoning(request), request)
|
||||||
|
self.assertEqual(
|
||||||
|
request["chat_template_kwargs"],
|
||||||
|
{"enable_thinking": False},
|
||||||
|
)
|
||||||
|
self.assertEqual(request["thinking_budget_tokens"], 0)
|
||||||
|
|
||||||
|
def test_native_thinking_budget_is_preserved_without_openai_effort(self) -> None:
|
||||||
|
request = {"thinking_budget_tokens": 1234, "messages": []}
|
||||||
|
self.assertIs(_normalize_llamacpp_reasoning(request), request)
|
||||||
|
self.assertEqual(request["thinking_budget_tokens"], 1234)
|
||||||
self.assertNotIn("chat_template_kwargs", request)
|
self.assertNotIn("chat_template_kwargs", request)
|
||||||
|
|
||||||
|
|
||||||
@@ -196,6 +223,53 @@ class ChatGenerationLimitTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSystemPolicyTests(unittest.TestCase):
|
||||||
|
def _inject(self, request: dict, path: str,
|
||||||
|
policy: str = "Verify facts.") -> dict:
|
||||||
|
with patch("ai_profile_router._load_global_system_policy",
|
||||||
|
return_value=policy):
|
||||||
|
return _inject_global_system_policy(request, path)
|
||||||
|
|
||||||
|
def test_chat_policy_precedes_existing_system_prompt(self) -> None:
|
||||||
|
request = {"messages": [
|
||||||
|
{"role": "system", "content": "Client policy."},
|
||||||
|
{"role": "user", "content": "Hello"},
|
||||||
|
]}
|
||||||
|
normalized = self._inject(request, "/v1/chat/completions")
|
||||||
|
self.assertEqual(
|
||||||
|
normalized["messages"][0]["content"],
|
||||||
|
"Verify facts.\n\nClient policy.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_chat_policy_is_inserted_without_system_prompt(self) -> None:
|
||||||
|
request = {"messages": [{"role": "user", "content": "Hello"}]}
|
||||||
|
normalized = self._inject(request, "/v1/chat/completions")
|
||||||
|
self.assertEqual(normalized["messages"][0], {
|
||||||
|
"role": "system", "content": "Verify facts.",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_policy_is_not_duplicated(self) -> None:
|
||||||
|
request = {"messages": [{
|
||||||
|
"role": "system", "content": "Verify facts.\n\nClient policy.",
|
||||||
|
}]}
|
||||||
|
normalized = self._inject(request, "/v1/chat/completions")
|
||||||
|
self.assertEqual(
|
||||||
|
normalized["messages"][0]["content"].count("Verify facts."), 1)
|
||||||
|
|
||||||
|
def test_responses_policy_precedes_instructions(self) -> None:
|
||||||
|
request = {"instructions": "Client policy.", "input": "Hello"}
|
||||||
|
normalized = self._inject(request, "/v1/responses")
|
||||||
|
self.assertEqual(
|
||||||
|
normalized["instructions"],
|
||||||
|
"Verify facts.\n\nClient policy.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unrelated_endpoint_is_unchanged(self) -> None:
|
||||||
|
request = {"prompt": "Draw a cat"}
|
||||||
|
self.assertEqual(
|
||||||
|
self._inject(request, "/v1/images/generations"), request)
|
||||||
|
|
||||||
|
|
||||||
class RetentionTests(unittest.TestCase):
|
class RetentionTests(unittest.TestCase):
|
||||||
def test_oldest_pairs_are_removed(self) -> None:
|
def test_oldest_pairs_are_removed(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
|||||||
Executable
+172
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-shot recovery orchestrator for system-disk, data-disk and total loss.
|
||||||
|
# It never partitions, formats, reboots or shuts down the host.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SCENARIO=""
|
||||||
|
ARCHIVE=/data/docker-backups/athena-latest.tar.gz
|
||||||
|
RECOVERY_CONFIG=""
|
||||||
|
INSTALL_CONFIG=""
|
||||||
|
SNAPSHOT=latest
|
||||||
|
PORTABLE=""
|
||||||
|
AGE_IDENTITY=""
|
||||||
|
WORK=""
|
||||||
|
|
||||||
|
log() { printf '\n==> %s\n' "$*"; }
|
||||||
|
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||||
|
cleanup() { [[ -z $WORK ]] || rm -rf "$WORK"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Systemplatte defekt, vorhandene /data-Platte:
|
||||||
|
sudo ./disaster-recovery.sh --scenario system \
|
||||||
|
--archive /data/docker-backups/athena-latest.tar.gz
|
||||||
|
|
||||||
|
Datenplatte defekt, Systemplatte vorhanden:
|
||||||
|
sudo ./disaster-recovery.sh --scenario data \
|
||||||
|
--config /root/athena-recovery.env
|
||||||
|
|
||||||
|
Beide Platten neu:
|
||||||
|
sudo ./disaster-recovery.sh --scenario all \
|
||||||
|
--config /root/athena-recovery.env
|
||||||
|
|
||||||
|
Alternativ bei Daten-/Totalausfall mit einem zuvor heruntergeladenen Paket:
|
||||||
|
sudo ./disaster-recovery.sh --scenario all \
|
||||||
|
--portable /pfad/athena-portable-....tar.zst.age \
|
||||||
|
--identity /root/athena-recovery-key.txt
|
||||||
|
|
||||||
|
Voraussetzung: Debian ist installiert und die richtige, bereits formatierte
|
||||||
|
Datenpartition ist separat unter /data eingehängt. Dieses Skript formatiert
|
||||||
|
keine Datenträger und führt niemals selbst einen Neustart aus.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--scenario) SCENARIO=${2:-}; shift 2 ;;
|
||||||
|
--archive) ARCHIVE=${2:-}; shift 2 ;;
|
||||||
|
--config) RECOVERY_CONFIG=${2:-}; shift 2 ;;
|
||||||
|
--install-config) INSTALL_CONFIG=${2:-}; shift 2 ;;
|
||||||
|
--snapshot) SNAPSHOT=${2:-}; shift 2 ;;
|
||||||
|
--portable) PORTABLE=${2:-}; shift 2 ;;
|
||||||
|
--identity) AGE_IDENTITY=${2:-}; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "Unbekanntes Argument: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||||
|
[[ $SCENARIO == system || $SCENARIO == data || $SCENARIO == all ]] || \
|
||||||
|
die "--scenario muss system, data oder all sein."
|
||||||
|
mountpoint -q /data || die "/data ist kein eigener Mountpoint. Abbruch zum Schutz der Systemplatte."
|
||||||
|
|
||||||
|
install_bootstrap_packages() {
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates gzip rsync tar restic
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_tree() {
|
||||||
|
local source=$1 target=$2
|
||||||
|
[[ -d $source ]] || return 0
|
||||||
|
install -d -m 0755 "$target"
|
||||||
|
rsync -a "$source/" "$target/"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_local_bootstrap() {
|
||||||
|
[[ -s $ARCHIVE ]] || die "Lokales Backup fehlt: $ARCHIVE"
|
||||||
|
gzip -t "$ARCHIVE" || die "Lokales Backup ist beschädigt."
|
||||||
|
WORK=$(mktemp -d /tmp/athena-system-recovery.XXXXXX)
|
||||||
|
tar -xzf "$ARCHIVE" -C "$WORK"
|
||||||
|
[[ -d $WORK/backup/etc-mike-ai ]] || die "Backup enthält /etc/mike-ai nicht."
|
||||||
|
copy_tree "$WORK/backup/etc-mike-ai" /etc/mike-ai
|
||||||
|
if [[ -d $WORK/backup/opt-mike-ai ]]; then
|
||||||
|
copy_tree "$WORK/backup/opt-mike-ai" /opt/mike-ai
|
||||||
|
elif [[ -d $WORK/backup/stack ]]; then
|
||||||
|
copy_tree "$WORK/backup/stack" /opt/mike-ai/stack
|
||||||
|
fi
|
||||||
|
install -d -m 0700 /var/lib/mike-ai-disaster-backup/latest
|
||||||
|
install -m 0600 "$ARCHIVE" \
|
||||||
|
/var/lib/mike-ai-disaster-backup/latest/docker-state.tar.gz
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_external_snapshot() {
|
||||||
|
[[ -r $RECOVERY_CONFIG ]] || die "Externe Recovery-Konfiguration fehlt: $RECOVERY_CONFIG"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$RECOVERY_CONFIG"
|
||||||
|
[[ -n ${RESTIC_REPOSITORY:-} ]] || die "RESTIC_REPOSITORY fehlt."
|
||||||
|
[[ -n ${RESTIC_PASSWORD_FILE:-} && -r $RESTIC_PASSWORD_FILE ]] || die \
|
||||||
|
"Der separat aufzubewahrende Restic-Schlüssel fehlt."
|
||||||
|
WORK=$(mktemp -d /tmp/athena-offsite-recovery.XXXXXX)
|
||||||
|
restic restore "$SNAPSHOT" --tag "${RESTIC_TAG:-athena-disaster}" --target "$WORK"
|
||||||
|
[[ -d $WORK/data ]] || die "Snapshot enthält keine Athena-Daten."
|
||||||
|
copy_tree "$WORK/data" /data
|
||||||
|
if [[ $SCENARIO == all ]]; then
|
||||||
|
copy_tree "$WORK/etc/mike-ai" /etc/mike-ai
|
||||||
|
copy_tree "$WORK/opt/mike-ai" /opt/mike-ai
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_portable_archive() {
|
||||||
|
[[ -s $PORTABLE ]] || die "Portables Backup fehlt: $PORTABLE"
|
||||||
|
[[ -r $AGE_IDENTITY ]] || die "Age-Identität fehlt: $AGE_IDENTITY"
|
||||||
|
WORK=$(mktemp -d /tmp/athena-portable-recovery.XXXXXX)
|
||||||
|
age --decrypt -i "$AGE_IDENTITY" "$PORTABLE" | zstd -d | tar -xf - -C "$WORK"
|
||||||
|
[[ -d $WORK/data ]] || die "Portables Backup enthält keine Athena-Daten."
|
||||||
|
copy_tree "$WORK/data" /data
|
||||||
|
if [[ $SCENARIO == all ]]; then
|
||||||
|
copy_tree "$WORK/etc/mike-ai" /etc/mike-ai
|
||||||
|
copy_tree "$WORK/opt/mike-ai" /opt/mike-ai
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_installer() {
|
||||||
|
local config=${INSTALL_CONFIG:-/etc/mike-ai/install.env}
|
||||||
|
[[ -r $config ]] || die \
|
||||||
|
"Installationskonfiguration fehlt: $config (alternativ --install-config angeben)."
|
||||||
|
chmod 0600 "$config"
|
||||||
|
[[ -x /opt/mike-ai/stack/install.sh ]] || die "Wiederhergestellter Stack fehlt."
|
||||||
|
set +e
|
||||||
|
/opt/mike-ai/stack/install.sh --config "$config"
|
||||||
|
local rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ $rc == 20 || $rc == 21 ]]; then
|
||||||
|
printf '\nEin kontrollierter Neustart ist für Treiber/Netzwerk nötig.\n'
|
||||||
|
printf 'Danach exakt denselben Disaster-Recovery-Befehl erneut ausführen.\n'
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
[[ $rc == 0 ]] || die "Installer fehlgeschlagen (Exit $rc)."
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_docker_state() {
|
||||||
|
local state=/var/lib/mike-ai-disaster-backup/latest/docker-state.tar.gz
|
||||||
|
if [[ ! -s $state && -n $WORK ]]; then
|
||||||
|
state=$(find "$WORK/var/lib/mike-ai-disaster-backup/latest" \
|
||||||
|
-maxdepth 1 -name docker-state.tar.gz -type f -print -quit 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
[[ -s $state ]] || die "Docker-Zustandsarchiv fehlt im Backup."
|
||||||
|
/opt/mike-ai/stack/restore.sh --check "$state"
|
||||||
|
/opt/mike-ai/stack/restore.sh "$state"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_bootstrap_packages
|
||||||
|
if [[ $SCENARIO == system ]]; then
|
||||||
|
restore_local_bootstrap
|
||||||
|
elif [[ -n $PORTABLE ]]; then
|
||||||
|
restore_portable_archive
|
||||||
|
else
|
||||||
|
restore_external_snapshot
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In the data-only case the source/configuration remain on the system disk.
|
||||||
|
[[ -x /opt/mike-ai/stack/install.sh ]] || die "/opt/mike-ai/stack fehlt."
|
||||||
|
run_installer
|
||||||
|
restore_docker_state
|
||||||
|
/opt/mike-ai/stack/platform/recovery/rebuild-specialized.sh
|
||||||
|
/opt/mike-ai/stack/smoke-test.sh
|
||||||
|
|
||||||
|
printf '\nATHENA_DISASTER_RECOVERY_OK scenario=%s\n' "$SCENARIO"
|
||||||
|
printf 'Athena läuft im LLM-Standardmodus; Spezial-GPU-Worker bleiben gestoppt.\n'
|
||||||
+52
-8
@@ -6,8 +6,12 @@ flowchart LR
|
|||||||
H -->|OpenAI API| R[Profile Router<br/>Athena :8081]
|
H -->|OpenAI API| R[Profile Router<br/>Athena :8081]
|
||||||
R --> P[Profile Controller]
|
R --> P[Profile Controller]
|
||||||
P --> Q[genau ein llama.cpp-Profil<br/>Qwen Fast / Medium / Large / Ultra / Uncensored]
|
P --> Q[genau ein llama.cpp-Profil<br/>Qwen Fast / Medium / Large / Ultra / Uncensored]
|
||||||
R --> I[FLUX.2-klein-4B<br/>RTX 5080, Text + Editing]
|
R --> I[FLUX.2 Klein 9B FP8 Beta<br/>RTX 5080 Transformer]
|
||||||
R --> T[XTTS RTX 3060<br/>Piper CPU-Fallback]
|
I --> E[Qwen3-8B NF4 Textencoder<br/>RTX 3060 während Bildauftrag]
|
||||||
|
R --> T[Qwen3-TTS RTX 3060<br/>Normalisierungs- und Streaming-Gateway]
|
||||||
|
R --> STT[Whisper.cpp ggml-small<br/>CPU, lokale Spracherkennung]
|
||||||
|
P --> SP[Exklusive Spezialworker<br/>Musik / Trennung / Voice / RVC / 3D]
|
||||||
|
SP --> TR[TRELLIS.2 4B Q8<br/>trellis.cpp, RTX 5080]
|
||||||
|
|
||||||
H --> U[MUA / Unraid MCP]
|
H --> U[MUA / Unraid MCP]
|
||||||
H --> A[ARR-MCP]
|
H --> A[ARR-MCP]
|
||||||
@@ -15,11 +19,13 @@ flowchart LR
|
|||||||
H --> N[Navidrome-MCP]
|
H --> N[Navidrome-MCP]
|
||||||
H --> S[STRATO-MCP]
|
H --> S[STRATO-MCP]
|
||||||
H --> X[Nginx-Proxy-Manager-MCP]
|
H --> X[Nginx-Proxy-Manager-MCP]
|
||||||
U --> M[Media-Tools<br/>ffmpeg / ffprobe / yt-dlp]
|
U --> MT[Media-Tools<br/>ffmpeg / ffprobe / yt-dlp]
|
||||||
|
|
||||||
W[WireGuard-Gateway<br/>Athena] --- R
|
W[WireGuard-Gateway<br/>Athena] -->|DNS-Proxy| R
|
||||||
W --- B[Athena Dashboard :8099]
|
W -->|DNS-Proxy :8099| B[Athena Dashboard<br/>internes Frontend-Netz]
|
||||||
W --- O[Athena Operator]
|
W -->|DNS-Proxy :9443| PRT[Portainer<br/>internes Frontend-Netz]
|
||||||
|
W -->|DNS-Proxy :8013| TRUI[Trellis Studio<br/>Bild zu GLB]
|
||||||
|
W -->|DNS-Proxy| O[Athena Operator]
|
||||||
K[Backup alle 5 Stunden] --> DATA[/data und /etc/mike-ai]
|
K[Backup alle 5 Stunden] --> DATA[/data und /etc/mike-ai]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,6 +37,12 @@ flowchart LR
|
|||||||
- **MUA** verwaltet Unraid. **Athena Operator** bleibt auf den Athena-Host
|
- **MUA** verwaltet Unraid. **Athena Operator** bleibt auf den Athena-Host
|
||||||
begrenzt.
|
begrenzt.
|
||||||
- Der Router ist die einzige Modelladresse, die Hermes kennen muss.
|
- Der Router ist die einzige Modelladresse, die Hermes kennen muss.
|
||||||
|
- GPU-intensive Spezialdienste sind gegenseitig exklusiv. Der Router speichert
|
||||||
|
Modus und Rückkehrprofil; der Profile Controller startet nur eindeutig
|
||||||
|
gelabelte Worker.
|
||||||
|
- Dashboard und Portainer besitzen eigene Netzwerk-Namespaces. Das
|
||||||
|
WireGuard-Gateway löst ihre stabilen Compose-Dienstnamen bei jeder
|
||||||
|
Verbindung neu auf; seine konkrete Container-ID ist damit irrelevant.
|
||||||
|
|
||||||
## Dynamische Qwen-Profile
|
## Dynamische Qwen-Profile
|
||||||
|
|
||||||
@@ -46,7 +58,39 @@ Kontextgröße:
|
|||||||
| Ultra | 262.144 Token |
|
| Ultra | 262.144 Token |
|
||||||
| Uncensored | 80.000 Token |
|
| Uncensored | 80.000 Token |
|
||||||
|
|
||||||
|
## Exklusiver Bildmodus
|
||||||
|
|
||||||
|
Text- und Bildinferenz teilen sich dieselben GPUs und laufen deshalb nicht
|
||||||
|
gleichzeitig. Der Wechsel ist transaktional:
|
||||||
|
|
||||||
|
1. Router merkt sich das aktive Textprofil.
|
||||||
|
2. Profile Controller stoppt alle llama.cpp-Profile und Qwen3-TTS.
|
||||||
|
3. Bild-Worker lädt Qwen3-8B als NF4-Textencoder auf die RTX 3060 und den
|
||||||
|
FLUX.2-Klein-9B-FP8-Transformer auf die RTX 5080.
|
||||||
|
4. Nach dem Prompt-Encoding werden die Embeddings zur RTX 5080 übertragen.
|
||||||
|
5. Vor dem VAE-Decoding werden Textencoder und Transformer freigegeben.
|
||||||
|
6. Der Worker wird gestoppt; anschließend starten Qwen3-TTS und das vorherige
|
||||||
|
Textprofil wieder. Während der exklusiven Bildphase steht kein TTS bereit.
|
||||||
|
|
||||||
|
Der Bild-Worker ist lazy und besitzt `restart: "no"`; im normalen Textbetrieb
|
||||||
|
belegt er daher keinen VRAM. Container werden über eindeutige Docker-Labels
|
||||||
|
gefunden, nicht über zufällige Container-IDs.
|
||||||
|
|
||||||
Die visuelle Fassung liegt als `athena-architecture-map.png` neben dieser Datei.
|
Die visuelle Fassung liegt als `athena-architecture-map.png` neben dieser Datei.
|
||||||
Eine zweite Detailkarte, `athena-gpu-allocation-map.png`, zeigt die
|
Eine zweite Detailkarte, `athena-gpu-allocation-map.png`, zeigt die
|
||||||
profilabhängige Layer-Verteilung auf RTX 5080 und RTX 3060 sowie die festen
|
profilabhängige Layer-Verteilung auf RTX 5080 und RTX 3060. Die PNG-Karten
|
||||||
GPU-Zuordnungen von FLUX.2, Vision-Projektor und XTTS.
|
zeigen noch den Stand vor dem 9B-Bildpfad; die aktuelle textuelle Beschreibung
|
||||||
|
in diesem Dokument ist verbindlich.
|
||||||
|
|
||||||
|
## TRELLIS.2 3D-Modus
|
||||||
|
|
||||||
|
Der Modus `trellis` stoppt die anderen GPU-Worker und startet genau den mit
|
||||||
|
`com.mike-ai.trellis-worker=trellis2-q8` markierten Container. trellis.cpp
|
||||||
|
0.6.0 sieht ausschließlich die Host-GPU 1, die RTX 5080. Q8-Gewichte liegen
|
||||||
|
unter `/data/models/trellis2-q8`, Runtime und Ausgaben unter
|
||||||
|
`/data/trellis-studio`. Die UI ist intern `trellis-studio:8080` und wird vom
|
||||||
|
WireGuard-Gateway auf `192.168.1.212:8013` weitergeleitet. Sie erzeugt GLB;
|
||||||
|
regulärer Qualitätsmodus ist 1024 Pixel.
|
||||||
|
|
||||||
|
Die vollständigen Regeln für Erweiterungen, Rückbau und Fehlersuche stehen in
|
||||||
|
[`../for_ki.md`](../for_ki.md).
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Container-Inventar auf Athena
|
||||||
|
|
||||||
|
Stand: 10. September 2026
|
||||||
|
|
||||||
|
Athena besteht nach der Bereinigung aus 23 Docker-Containern. Nicht jeder Container enthält
|
||||||
|
ein KI-Modell: Router, Oberflächen, Netzwerk, Steuerung und Sicherung sind
|
||||||
|
gewöhnliche Dienste. Die rechenintensiven GPU-Worker werden absichtlich nur bei
|
||||||
|
Bedarf gestartet. Ein Container im Zustand `Created` oder `Exited (0)` ist daher
|
||||||
|
nicht automatisch ein ungenutzter Rest.
|
||||||
|
|
||||||
|
| Container | Modell oder wesentliche Komponente | Aufgabe |
|
||||||
|
|---|---|---|
|
||||||
|
| `mike-ai-backup` | kein Modell; Offen Docker Volume Backup | Sichert `/data`, `/etc/mike-ai`, den Stack und die persistenten Docker-Volumes im Fünf-Stunden-Takt. |
|
||||||
|
| `mike-ai-applio-studio` | Applio/RVC; Stimmenmodelle werden nutzerseitig ergänzt | Vollständige RVC-Oberfläche für Inferenz, Modellverwaltung und Training auf der RTX 5080. Für eine Konvertierung ist ein importiertes oder trainiertes `.pth`-Modell nötig; eine Referenzaufnahme allein reicht nicht. |
|
||||||
|
| `mike-ai-image-worker` | FLUX.2 Klein 9B FP8, Qwen3-8B NF4 Textencoder und VAE | Erzeugt und bearbeitet Bilder transaktional; nutzt während eines Auftrags RTX 5080 und RTX 3060. |
|
||||||
|
| `mike-ai-llama-dashboard` | kein Modell | Zeigt Telemetrie, Profile, GPU-Nutzung und Betriebsarten an und bietet die Modusumschaltung. |
|
||||||
|
| `mike-ai-llama-fast` | Qwen3.8-27B `IQ4-MIX`, Qwen-MMProj BF16 | Schnelles Q4-Text-/Vision-Profil mit 76.800 Token Kontext. |
|
||||||
|
| `mike-ai-llama-large` | Qwen3.8-27B `IQ4_XS-pure`, Qwen-MMProj BF16 | Q4-Text-/Vision-Profil mit 192.000 Token Kontext und Verteilung auf beide GPUs. |
|
||||||
|
| `mike-ai-llama-medium` | Qwen3.8-27B `IQ4_XS-pure`, Qwen-MMProj BF16 | Standard-Q4-Text-/Vision-Profil mit 160.000 Token Kontext und Verteilung auf beide GPUs. |
|
||||||
|
| `mike-ai-llama-ultra` | Qwen3.8-27B `IQ4_XS-pure`, ohne Vision-Projektor | Maximales Langkontextprofil mit 262.144 Token Kontext und Verteilung auf beide GPUs. |
|
||||||
|
| `mike-ai-llama-uncensored` | Qwen3.8-27B Abliterated `Q4_K_M`, eigener MMProj F16 | Spezialprofil mit 80.000 Token Kontext und gelockerten Modellgrenzen. |
|
||||||
|
| `mike-ai-mcp-athena-operator` | kein Modell | Stellt Hermes begrenzte Werkzeuge zum Prüfen, Ändern, Testen, Sichern und Versionieren von Athena bereit. |
|
||||||
|
| `mike-ai-music-acestep-test` | ACE-Step 1.5 XL-SFT und `acestep-5Hz-lm-1.7B` | Generiert Musik im exklusiven Musikmodus auf der RTX 5080. |
|
||||||
|
| `mike-ai-music-ui` | kein Modell; `fspecii/ace-step-ui` | Community-Oberfläche für ACE-Step; bleibt als leichte UI verfügbar, während der GPU-Worker bedarfsgesteuert läuft. |
|
||||||
|
| `mike-ai-portainer` | kein Modell; Portainer CE | Optionale Docker-Verwaltungsoberfläche. |
|
||||||
|
| `mike-ai-profile-controller` | kein Modell | Startet und stoppt ausschließlich freigegebene Modellprofile und Spezialworker in einer sicheren Reihenfolge. |
|
||||||
|
| `mike-ai-qwen3-tts` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base`, Stimme Serena | Hochwertige deutsche Sprachausgabe auf der RTX 3060 im LLM-Betrieb. |
|
||||||
|
| `mike-ai-router` | kein eigenes Modell | Einzige OpenAI-kompatible Modelladresse; koordiniert Profile, Bildaufträge, Sprache und Betriebsarten. |
|
||||||
|
| `mike-ai-stem-separator` | BS-RoFormer Viperx 1297, `htdemucs_ft`, `htdemucs_6s`, `MossFormer2_SE_48K` | Trennt Gesang, Instrumente oder Sprache/Hintergrundgeräusche im exklusiven Separationsmodus. |
|
||||||
|
| `mike-ai-trellis-studio` | TRELLIS.2 4B Q8 über trellis.cpp 0.6.0 | Erzeugt im exklusiven 3D-Modus aus einem Bild ein texturiertes, geschlossen aufbereitetes GLB-Mesh. Nutzt ausschließlich die RTX 5080 und wird über Port 8013 bedient. |
|
||||||
|
| `mike-ai-yue2-playground` | YuE2-3B mit Ladypoly `YuE2_WebUI` | Eigenständiges Musikstudio für Generierung, Score-basierte Steuerung und SheetSage2-Audioanalyse/Remix. Läuft exklusiv zu ACE-Step und allen übrigen GPU-Diensten; Zugriff über Port 8014. |
|
||||||
|
| `mike-ai-tts-gateway` | kein eigenes Modell | Normalisiert Text, konvertiert Ausgabeformate und stellt Qwen3-TTS sowie natives PCM-Streaming über eine stabile interne API bereit. |
|
||||||
|
| `mike-ai-voice-studio` | `k2-fsa/OmniVoice` 0.2.1 mit Whisper-ASR | Erzeugt Text-to-Speech mit einer Referenzstimme; kein Audio-to-Audio-Voice-Changer. |
|
||||||
|
| `mike-ai-whisper` | Whisper.cpp `ggml-small` | Lokale deutsche Spracherkennung auf der CPU über `/v1/audio/transcriptions`. |
|
||||||
|
| `mike-ai-wireguard-gateway` | kein Modell | Veröffentlicht Dashboard und Fachoberflächen ausschließlich über den privaten WireGuard-Pfad. |
|
||||||
|
| `mike-ai-xvc-studio` | `chenxie95/X-VC`, GLM-4-Voice-Tokenizer und optional Resemble Enhance | Wandelt eine vorhandene Sprachaufnahme anhand einer Referenzstimme in Audio zu Audio um; gibt das native 16-kHz-Ergebnis und optional eine neural restaurierte 44,1-kHz-Fassung aus. |
|
||||||
|
|
||||||
|
## Aufräumregel
|
||||||
|
|
||||||
|
Vor dem Löschen muss ein Kandidat gegen Compose-Dateien, Docker-Labels,
|
||||||
|
Mounts, Router-/Controller-Verweise und `/data` geprüft werden. Entfernt werden
|
||||||
|
nur nachweislich abgelöste Images, Gewichte, Versuchsdaten und Build-Caches.
|
||||||
|
Gewollt gestoppte Profilcontainer, persistente Modell-Caches und die letzte
|
||||||
|
funktionierende Produktionsvariante bleiben erhalten.
|
||||||
|
|
||||||
|
Am 9. September wurden die verworfenen Vevo2-Images und -Daten, das alte
|
||||||
|
Vevo2-Projektverzeichnis, ein leeres Test-Lab sowie der Docker-Build-Cache
|
||||||
|
entfernt. Der Build-Cache allein gab 74,43 GB frei; `/data` besitzt danach rund
|
||||||
|
562 GB freien Speicher. Kein produktiver oder bedarfsgesteuerter Container
|
||||||
|
wurde gelöscht.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Aktueller produktiver Laufzustand
|
||||||
|
|
||||||
|
Stand: 10. September 2026
|
||||||
|
|
||||||
|
## TRELLIS.2 3D-Studio
|
||||||
|
|
||||||
|
TRELLIS.2 4B läuft über trellis.cpp 0.6.0 als exklusiver Q8-Worker auf der
|
||||||
|
RTX 5080. Runtime und Q8-Gewichte liegen getrennt unter
|
||||||
|
`/data/trellis-studio` und `/data/models/trellis2-q8`; die Browseroberfläche
|
||||||
|
ist im WireGuard-Netz unter `http://192.168.1.212:8013` erreichbar. Ein realer
|
||||||
|
512er Ende-zu-Ende-Test erzeugte in 54,2 Sekunden ein gültiges 4,4-MB-GLB.
|
||||||
|
Für reguläre Qualitätsläufe ist `1024 · cascade` vorgesehen.
|
||||||
|
|
||||||
|
## Fotorestaurierung verworfen
|
||||||
|
|
||||||
|
Der versuchsweise HYPIR-SD2-Restaurationspfad wurde vollständig aus Router,
|
||||||
|
Compose und Hermes entfernt. HYPIR glättete beziehungsweise erfand beim realen
|
||||||
|
Testfoto Details; ein isolierter SeedVR2-7B-FP8-Test bewahrte das Motiv besser,
|
||||||
|
lieferte bei der starken Bewegungsunschärfe aber keinen ausreichenden
|
||||||
|
Qualitätsgewinn. Athena veröffentlicht deshalb kein Modell `restauration` und
|
||||||
|
Hermes besitzt keinen entsprechenden Skill mehr.
|
||||||
|
|
||||||
|
FLUX.2 Klein 9B bleibt für Bildgenerierung und kreative Referenzbild-Edits
|
||||||
|
aktiv. Details und Abnahmekriterien stehen in
|
||||||
|
[IMAGE_RESTORATION.md](IMAGE_RESTORATION.md).
|
||||||
|
|
||||||
|
## FLUX.2 Klein 9B FP8 Beta
|
||||||
|
|
||||||
|
Die bisherige 4B-Bildinferenz wurde testweise durch FLUX.2 Klein 9B FP8
|
||||||
|
ersetzt. Athenas Profile Controller stellt dafür einen exklusiven Zwei-GPU-Pfad
|
||||||
|
bereit:
|
||||||
|
|
||||||
|
- RTX 5080: 9B-FP8-Diffusionstransformer und VAE-Decoding
|
||||||
|
- RTX 3060: Qwen3-8B-Textencoder in NF4
|
||||||
|
- Qwen3-TTS und aktives llama.cpp-Profil werden für den Bildauftrag pausiert
|
||||||
|
- TTS ist währenddessen vorübergehend nicht verfügbar
|
||||||
|
- nach Abschluss werden TTS und das vorherige Textprofil wiederhergestellt
|
||||||
|
|
||||||
|
Ein vollständiger Aufruf über Athenas OpenAI-kompatiblen Router wurde mit
|
||||||
|
HTTP 200, einem korrekt gespeicherten 1024×1024-PNG und anschließender
|
||||||
|
Wiederherstellung von Qwen3-TTS und `qwen-fast` erfolgreich geprüft. Ein
|
||||||
|
isolierter Vergleich ergab ungefähr 14,6 Sekunden Bildlaufzeit mit dem
|
||||||
|
GPU-Textencoder gegenüber 102,1 Sekunden mit CPU-Textencoder. Diese Werte sind
|
||||||
|
eine lokale Einzelmessung und keine allgemeine Modellgarantie.
|
||||||
|
|
||||||
|
Das Modell ist nicht kommerziell lizenziert. Die Bedingungen der beiden
|
||||||
|
zugriffsbeschränkten Black-Forest-Labs-Repositories müssen vor dem Download
|
||||||
|
akzeptiert werden. Details stehen in [FLUX_9B_BETA.md](FLUX_9B_BETA.md).
|
||||||
|
|
||||||
|
## Lokale Spracherkennung
|
||||||
|
|
||||||
|
Athena betreibt Whisper.cpp v1.9.1 mit `ggml-small` als CPU-Dienst. Der
|
||||||
|
Profile Router veröffentlicht ihn als OpenAI-kompatiblen Endpunkt
|
||||||
|
`/v1/audio/transcriptions`; Standardsprache ist Deutsch. Modell und Download
|
||||||
|
bleiben im persistenten Docker-Volume `whisper-data` erhalten.
|
||||||
|
|
||||||
|
Ein lokaler Rundlauftest (Athena-TTS → WAV → Athena-STT) wurde erfolgreich
|
||||||
|
durchgeführt. OpenClaw ist ebenfalls auf diesen lokalen Endpunkt eingestellt
|
||||||
|
und wurde mit `openclaw infer audio transcribe` erfolgreich geprüft. Für den
|
||||||
|
lokalen Provider ist der Zugriff auf Athenas private IP ausdrücklich erlaubt;
|
||||||
|
andere private Ziele werden dadurch nicht freigeschaltet.
|
||||||
|
|
||||||
|
## Produktive llama.cpp-Runtime
|
||||||
|
|
||||||
|
Alle Textprofile verwenden llama.cpp Build 10781,
|
||||||
|
Commit `c7bda030e7faee594dbe7550185e857351ad405d`. Der Stand enthält die ab
|
||||||
|
Build 10751 verfügbare Korrektur für eine zwischenzeitliche
|
||||||
|
MTP-/KV-Cache-Initialisierungsregression. Der vorherige produktive Stand war
|
||||||
|
Build 10718, Commit `41ef91f7c8046087cdfbb276b79bff311ecf1c6d`. Dessen lokales
|
||||||
|
Fallback-Image wurde am 8. September 2026 beim gezielten Aufräumen entfernt;
|
||||||
|
ein Rückfall erfordert daher einen Neubau dieses Commits.
|
||||||
|
|
||||||
|
Build 10781 wurde nach dem Bau produktiv verifiziert. Alle fünf
|
||||||
|
Profildefinitionen verwenden dasselbe neue Image. Am 8. September lief Ultra
|
||||||
|
mit 262.144 Tokens Kontext gesund; MTP und eine lokale Textprobe wurden
|
||||||
|
erfolgreich geprüft.
|
||||||
|
|
||||||
|
## Qwen Medium: Vision-Projektor wieder aktiviert
|
||||||
|
|
||||||
|
`qwen-medium` läuft wieder mit dem Qwen-Vision-Projektor. Der Projektor wird
|
||||||
|
über `--mmproj-offload --mmproj-device CUDA1` gezielt auf der RTX 3060 geladen.
|
||||||
|
Der vorübergehende Text-only-Workaround ist damit auf ausdrücklichen Wunsch
|
||||||
|
beendet.
|
||||||
|
|
||||||
|
Dabei gilt ausdrücklich:
|
||||||
|
|
||||||
|
- Der Gesamtkontext bleibt bei **160.000 Tokens**.
|
||||||
|
- Das Profil verwendet wieder **einen Slot**. Die getestete Zwei-Slot-Variante
|
||||||
|
ist nicht produktiv.
|
||||||
|
- **MTP / Speculative Decoding bleibt aktiviert**; MTP wurde nicht entfernt.
|
||||||
|
- Modell, Quantisierung, GPU-Aufteilung und KV-Cache-Quantisierung bleiben
|
||||||
|
unverändert.
|
||||||
|
- Bildanalyse ist im Medium-Profil wieder verfügbar.
|
||||||
|
- Der bekannte llama.cpp-/MMProj-Cachefehler kann weiterhin vollständige
|
||||||
|
Prompt-Neuverarbeitung in späteren Turns auslösen. Diese Einschränkung wird
|
||||||
|
zugunsten der benötigten Vision-Funktion bewusst akzeptiert.
|
||||||
|
|
||||||
|
Referenz:
|
||||||
|
|
||||||
|
- https://github.com/ggml-org/llama.cpp/issues/19858
|
||||||
|
- https://github.com/ggml-org/llama.cpp/issues/21133
|
||||||
|
|
||||||
|
## Separates bekanntes Problem
|
||||||
|
|
||||||
|
Automatische Hermes-Hintergrundanfragen können weiterhin den einzigen aktiven
|
||||||
|
llama.cpp-Slot belegen und damit den Cache eines großen Chats verdrängen. Dieses
|
||||||
|
Slot-Eviction-Problem ist unabhängig vom MMProj-Workaround und muss separat in
|
||||||
|
der Hermes-Auxiliary-/Hintergrundverarbeitung geklärt werden.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Dirk Qwen3.8-27B A/B benchmark (2026-09-01)
|
||||||
|
|
||||||
|
Candidate: `peculiar-ragdoll/Dirk-Qwen3.8-27B-GGUF`, pinned revision
|
||||||
|
`12362f2b3d7dc11044e99c9e7e99fb9f530528c0`, quant
|
||||||
|
`Dirk-Qwen3.8-27B-UD-Q4_K_XL.gguf`.
|
||||||
|
|
||||||
|
Reference: the production pure Qwen model
|
||||||
|
`qwen3.8-27b-IQ4_XS-pure.gguf`.
|
||||||
|
|
||||||
|
Both sides used the same local llama.cpp image and production-style runtime
|
||||||
|
settings: one slot, Q4_0 KV, unified KV, 24 GiB prompt cache, batch 2048,
|
||||||
|
ubatch 128 and embedded MTP with draft length 3. The container-visible GPU
|
||||||
|
order was CUDA0 = RTX 5080 and CUDA1 = RTX 3060. A separate Python process
|
||||||
|
occupied about 1.9 GiB on the RTX 3060 throughout the run and was deliberately
|
||||||
|
not disturbed.
|
||||||
|
|
||||||
|
## Stable candidate matrix
|
||||||
|
|
||||||
|
| Context | Stable split (5080:3060) | Short prefill | Short decode | Long tested prompt | Long prefill | Long decode | Recall |
|
||||||
|
|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| 80k | 87:13 | 1,381 t/s | 68.8 t/s | 56.2k | 1,127 t/s | 51.8 t/s | 3/3 |
|
||||||
|
| 160k | 80:20 | 1,268 t/s | 64.2 t/s | 112.3k | 854 t/s | 40.1 t/s | 3/3 |
|
||||||
|
| 192k | 72:28 | 1,124 t/s | 60.2 t/s | 134.7k | 677 t/s | 35.3 t/s | 3/3 |
|
||||||
|
| 262,144 | 70:30 | 1,076 t/s | 59.6 t/s | 183.8k | 539 t/s | 29.9 t/s | 3/3 |
|
||||||
|
|
||||||
|
At 80k, 88:12 loaded but failed on the first real prefill; 87:13 was the
|
||||||
|
maximum practical split. At 262k, 68:32 exhausted the RTX 3060 during KV
|
||||||
|
allocation and 72:28 exhausted the RTX 5080 during compute-buffer allocation.
|
||||||
|
70:30 was the only tested midpoint that loaded and completed the 183.8k-token
|
||||||
|
recall probe.
|
||||||
|
|
||||||
|
## Fair 160k comparison
|
||||||
|
|
||||||
|
| Model | Split | Short prefill | Short decode | 112k prefill | 112k decode | Recall |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Pure IQ4_XS | 85:15 | 1,479 t/s | 104.4 t/s | 954 t/s | 56.4 t/s | 3/3 |
|
||||||
|
| Dirk Q4_K_XL | 80:20 | 1,268 t/s | 64.2 t/s | 854 t/s | 40.1 t/s | 3/3 |
|
||||||
|
|
||||||
|
Dirk was 14% slower on short prefill, 11% slower on the long prefill, 38%
|
||||||
|
slower on short decode and 29% slower on long decode.
|
||||||
|
|
||||||
|
## Quality and tool use
|
||||||
|
|
||||||
|
The fixed acceptance set covered logic, evidence-based diagnosis, concurrent
|
||||||
|
Python, capacity planning, prompt-injection resistance, configuration versus
|
||||||
|
runtime state, safe read-only diagnostics and evidence boundaries. Both models
|
||||||
|
emitted the requested native function call with the correct argument.
|
||||||
|
|
||||||
|
| Model | Completion tokens | Total task wall time | Mean decode | Completed final answers |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| Pure IQ4_XS | 17,542 | 224.9 s | 77.2 t/s | 7/9 before limit |
|
||||||
|
| Dirk Q4_K_XL | 12,178 | 260.2 s | 47.5 t/s | 9/9 |
|
||||||
|
|
||||||
|
Dirk used about 31% fewer completion tokens and was more concise. It produced
|
||||||
|
the cleaner proof for the impossible live-migration task. Pure reached the
|
||||||
|
right conclusion but its state proof contained a source-host accounting error
|
||||||
|
and hit the output limit. Both concurrent-Python answers had a subtle remaining
|
||||||
|
edge case: simultaneously completed failing tasks outside the returned task
|
||||||
|
were not all gathered, so neither answer was perfect.
|
||||||
|
|
||||||
|
Despite producing fewer tokens, Dirk needed about 16% more wall time for the
|
||||||
|
whole quality set because decode was much slower.
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
The supplied F16 projector loaded at 160k with the 80:20 split. With reasoning
|
||||||
|
disabled, the private synthetic image was described correctly. Image prefill
|
||||||
|
was 106.7 t/s, decode was 39.7 t/s, and end-to-end latency was 11.0 seconds.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Do not replace the production Pure Qwen medium profile with Dirk. Pure is the
|
||||||
|
clear speed winner and retained the same long-context recall and tool-call
|
||||||
|
ability. Dirk is useful only as an optional high-context/concise profile: it
|
||||||
|
can provide a verified 262k configured context on both GPUs and tends to spend
|
||||||
|
fewer output tokens, but it is slower in real elapsed time.
|
||||||
|
|
||||||
|
The test container was removed after the run. Production `mike-ai-llama-medium`
|
||||||
|
and `mike-ai-llama-review` were restarted and verified healthy.
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# FLUX.2 Klein 9B FP8 Beta auf Athena
|
||||||
|
|
||||||
|
Stand: 7. September 2026
|
||||||
|
|
||||||
|
## Zweck und Status
|
||||||
|
|
||||||
|
Der Bildpfad ersetzt testweise FLUX.2 Klein 4B durch das größere
|
||||||
|
FLUX.2-Klein-9B-Modell. Ziel sind bessere Prompttreue, räumliche Beziehungen,
|
||||||
|
Objektkonsistenz und Referenzbild-Bearbeitung. Der Pfad ist technisch
|
||||||
|
funktionsfähig, bleibt aber bis zu weiteren Qualitäts- und Editing-Tests als
|
||||||
|
Beta bezeichnet.
|
||||||
|
|
||||||
|
Der OpenAI-kompatible Modellname lautet:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FLUX.2-klein-9B-fp8-beta
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modellartefakte und Lizenz
|
||||||
|
|
||||||
|
Verwendet werden zwei gepinnte, zugriffsbeschränkte Hugging-Face-Repositories:
|
||||||
|
|
||||||
|
| Zweck | Repository | Revision | Lokaler Pfad |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Pipeline-Komponenten, Qwen3-Textencoder und VAE | `black-forest-labs/FLUX.2-klein-9B` | `92196c8e11f7b6cf2b7493e037d8c5345c559216` | `/data/models/FLUX.2-klein-9B-components` |
|
||||||
|
| FP8-Transformer | `black-forest-labs/FLUX.2-klein-9b-fp8` | `902d9d510b51533e07729f19211414a3648b77d2` | `/data/models/FLUX.2-klein-9B-fp8` |
|
||||||
|
|
||||||
|
FLUX.2 Klein 9B steht unter der FLUX Non-Commercial License. Vor dem Download
|
||||||
|
müssen die Bedingungen beider Repositories im verwendeten Hugging-Face-Konto
|
||||||
|
akzeptiert werden. Ein Token gehört ausschließlich in die durch
|
||||||
|
`HF_TOKEN_FILE` angegebene, für root lesbare Datei; niemals in Git oder
|
||||||
|
`stack.env`.
|
||||||
|
|
||||||
|
## GPU-Aufteilung
|
||||||
|
|
||||||
|
| Phase | RTX 5080, 16 GB | RTX 3060, 12 GB |
|
||||||
|
|---|---|---|
|
||||||
|
| Text-/Sprachbetrieb | aktives Qwen3.8-27B-Profil | Qwen3-TTS; Vision je nach Profil |
|
||||||
|
| Prompt-Encoding | FLUX-Transformer und VAE | Qwen3-8B-Textencoder, NF4 |
|
||||||
|
| Denoising | FLUX-Transformer | Textencoder wird nicht mehr benötigt |
|
||||||
|
| VAE-Decoding | VAE; Transformer zuvor freigegeben | Textencoder zuvor freigegeben |
|
||||||
|
|
||||||
|
Der Profile Controller stoppt vor dem Start des Bild-Workers alle
|
||||||
|
llama.cpp-Profile und den mit `com.mike-ai.tts-worker=qwen3` markierten
|
||||||
|
Qwen3-TTS-Container. Dadurch bleibt genügend VRAM für beide Bildkomponenten.
|
||||||
|
Nach dem Bildauftrag startet er Qwen3-TTS und das zuvor aktive Textprofil
|
||||||
|
wieder. Während des exklusiven GPU-Wechsels ist TTS vorübergehend nicht verfügbar.
|
||||||
|
|
||||||
|
## Aktuelle Grenzen
|
||||||
|
|
||||||
|
- genau 1024 × 1024 Pixel
|
||||||
|
- genau vier Inferenzschritte
|
||||||
|
- Guidance Scale 1,0
|
||||||
|
- ein Bildauftrag gleichzeitig
|
||||||
|
- höchstens vier bereits lokal gespeicherte Referenzbilder
|
||||||
|
- Textencoder-Maximum 128 Token
|
||||||
|
- Bildbearbeitung wird vom Worker angenommen, ist aber noch gesondert
|
||||||
|
Ende-zu-Ende zu qualifizieren
|
||||||
|
|
||||||
|
## Installation und Aktualisierung
|
||||||
|
|
||||||
|
In `/root/mike-ai-install.env` müssen diese Werte gesetzt sein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HF_TOKEN_FILE=/root/.cache/huggingface/token
|
||||||
|
FLUX_COMPONENT_DIR=/data/models/FLUX.2-klein-9B-components
|
||||||
|
FLUX_TRANSFORMER_DIR=/data/models/FLUX.2-klein-9B-fp8
|
||||||
|
```
|
||||||
|
|
||||||
|
Anschließend lädt der normale Installer nur die benötigten Komponenten und die
|
||||||
|
gepinnten FP8-Gewichte. Bestehende, vollständige Dateien werden nicht erneut
|
||||||
|
geladen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/mike-ai/stack
|
||||||
|
sudo ./install.sh --config /root/mike-ai-install.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Funktionsprobe
|
||||||
|
|
||||||
|
Der Router ist nur über das private Netz erreichbar. Ein minimaler Test lautet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://192.168.1.212:8081/v1/images/generations \
|
||||||
|
-H "Authorization: Bearer $ROUTER_API_KEY" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"model":"FLUX.2-klein-9B-fp8-beta",
|
||||||
|
"prompt":"A yellow toy excavator on the left and a red toy truck on the right, studio photo",
|
||||||
|
"size":"1024x1024",
|
||||||
|
"steps":4,
|
||||||
|
"guidance":1.0,
|
||||||
|
"seed":9072026
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach müssen folgende Zustände wiederhergestellt sein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format '{{.Names}} {{.Status}}' \
|
||||||
|
--filter name=mike-ai-router \
|
||||||
|
--filter name=mike-ai-qwen3-tts \
|
||||||
|
--filter name=mike-ai-llama
|
||||||
|
docker ps -a --filter name=mike-ai-image-worker \
|
||||||
|
--format '{{.Names}} {{.Status}}'
|
||||||
|
nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet werden ein gesunder Router, gesundes Qwen3-TTS, genau ein gesundes
|
||||||
|
llama.cpp-Profil und ein mit Exit-Code 0 beendeter Bild-Worker.
|
||||||
|
|
||||||
|
## Hermes
|
||||||
|
|
||||||
|
Hermes auf Unraid verwendet einen persistenten Benutzer-Provider
|
||||||
|
`athena-local`. Seine Konfiguration muss auf denselben Modellnamen zeigen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image_gen:
|
||||||
|
provider: athena-local
|
||||||
|
model: FLUX.2-klein-9B-fp8-beta
|
||||||
|
max_parallel_requests: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Provider lebt in Hermes-Appdata und bleibt bei normalen Container-Updates
|
||||||
|
erhalten. Er gehört nicht in die Desktop-App und muss auf weiteren Clients
|
||||||
|
nicht erneut installiert werden. Die versionierte Quellfassung liegt unter
|
||||||
|
[`integrations/hermes-athena-image`](../integrations/hermes-athena-image).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Die lokalen 4B-Gewichte und die kurzfristigen Rückfall-Images wurden am
|
||||||
|
8. September 2026 nach erfolgreicher 9B-Abnahme gezielt entfernt. Ein Rollback
|
||||||
|
auf 4B ist deshalb weiterhin reproduzierbar, aber nicht mehr unmittelbar: Das
|
||||||
|
4B-Modell muss erneut geladen und die ältere Stack-Fassung neu gebaut werden.
|
||||||
|
|
||||||
|
Die zugehörige Deployment-Sicherung liegt auf Athena unter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/deploy-backups/20260907-flux9b-beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Die vorherige Hermes-Konfiguration und der alte Provider liegen auf Unraid
|
||||||
|
unter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/nvme-storage/appdata/Hermes-Agent/backups/flux9b-beta-20260907
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Rollback darf nicht blind erfolgen: Zuerst aktives Profil, laufende
|
||||||
|
Anfragen und vorhandene Image-Tags prüfen, dann nur Image-Worker,
|
||||||
|
Profile Controller und Hermes-Provider auf den gesicherten Stand zurücksetzen.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Qwen Beta 1 – GSQ-RCO
|
||||||
|
|
||||||
|
> Historischer Testbericht. Das Beta-1-Profil wurde am 10. September 2026
|
||||||
|
> vollständig aus dem produktiven Router entfernt, weil die kleinere
|
||||||
|
> Quantisierung gegenüber den Q4-Profilen keinen belastbaren Vorteil brachte.
|
||||||
|
|
||||||
|
`qwen-beta-1` war ein zusätzliches, nicht standardmäßig aktives Router-Profil.
|
||||||
|
Der Bericht bleibt erhalten, damit diese Quantisierung nicht versehentlich
|
||||||
|
erneut getestet wird.
|
||||||
|
|
||||||
|
## Laufzeitkonfiguration
|
||||||
|
|
||||||
|
- Modell: Qwen3.8-27B GSQ-RCO IQ3_S MTP
|
||||||
|
- Kontext: 112.000 Token als konservativer Startwert
|
||||||
|
- Textmodell und KV-Cache: vollständig RTX 5080
|
||||||
|
- Vision-Projektor: RTX 3060
|
||||||
|
- KV-Quantisierung: Q4_0 für K und V
|
||||||
|
- MTP: 3 Draft-Token
|
||||||
|
- Batch / Micro-Batch: 2048 / 128
|
||||||
|
|
||||||
|
## Vorherige IQ3_XXS-Kontextgrenze
|
||||||
|
|
||||||
|
Eine echte Bildanfrage mit einer 2,3-MB-JPEG-Datei wurde zur Bestimmung der
|
||||||
|
VRAM-Grenze verwendet.
|
||||||
|
|
||||||
|
| Kontext | Ergebnis | Rest auf RTX 5080 nach Bildlauf |
|
||||||
|
|---:|---|---:|
|
||||||
|
| 192.000 | bestanden | ca. 129 MiB |
|
||||||
|
| 196.608 | bestanden, harte Kante | ca. 9 MiB |
|
||||||
|
| 197.120 | CUDA Out of Memory | ca. 1 MiB vor Abbruch |
|
||||||
|
|
||||||
|
Diese Werte gelten ausschließlich für die frühere, kleinere
|
||||||
|
`IQ3_XXS-MTP`-Datei. Sie dürfen nicht als Grenze der größeren
|
||||||
|
`IQ3_S-MTP`-Datei interpretiert werden. Das neue Profil startet bei 112.000
|
||||||
|
Token; seine technische und betrieblich sichere Grenze wird neu vermessen.
|
||||||
|
|
||||||
|
Beim erfolgreichen 196.608-Test erreichte die Bildanfrage rund 366 Prompt-
|
||||||
|
Token/s und 85 Ausgabe-Token/s. Das erkannte Bild wurde korrekt beschrieben.
|
||||||
|
|
||||||
|
## Austausch am 08.09.2026
|
||||||
|
|
||||||
|
Die bisherige `IQ3_XXS-MTP`-Datei wurde durch `IQ3_S-MTP` ersetzt. ISTA
|
||||||
|
berichtet für die 3,5-bpw-Variante gegenüber BF16 identische Ergebnisse auf
|
||||||
|
AIME25 und LiveCodeBench v6 sowie 0,51 Punkte Abstand auf GPQA-Diamond. Diese
|
||||||
|
Herstellermessungen rechtfertigen den A/B-Test, ersetzen aber keine lokale
|
||||||
|
Prüfung mit Hermes-, Werkzeug- und Langkontextaufgaben.
|
||||||
|
|
||||||
|
## Lokaler A/B-Test am 08.09.2026
|
||||||
|
|
||||||
|
Beide Dateien liefen mit 112.000 Kontext, Q4_0-K/V-Cache, MTP 3, identischem
|
||||||
|
Sampling und einem 85:15-Layer-Split über RTX 5080 und RTX 3060.
|
||||||
|
|
||||||
|
| Messung | IQ4_XS Pure | GSQ-RCO IQ3_S MTP |
|
||||||
|
|---|---:|---:|
|
||||||
|
| deterministische Kurzaufgaben | 24/25 | 24/25 |
|
||||||
|
| Decode, 512 Token | 65,6 Token/s | 59,3 Token/s |
|
||||||
|
| Prefill, 30 Token | 184,5 Token/s | 251,6 Token/s |
|
||||||
|
|
||||||
|
Beide Modelle machten denselben einzelnen Fehler bei `2^100 modulo 13`. Im
|
||||||
|
lokalen Kurztest war damit kein Qualitätsverlust der neuen Quantisierung
|
||||||
|
messbar. Der kurze Prefill-Wert ist nur ein Laufzeitindikator und kein
|
||||||
|
Langkontext-Benchmark.
|
||||||
|
|
||||||
|
In der produktiven Beta-1-Verteilung liegt das komplette Textmodell auf der
|
||||||
|
RTX 5080 und nur der Vision-Projektor auf der RTX 3060. Dort wurden 89,9
|
||||||
|
Token/s Decode gemessen; nach dem Lauf blieben etwa 1.051 MiB auf der RTX 5080
|
||||||
|
frei. Ein realer Bildtest beschrieb Motiv und sichtbaren Text korrekt. Das
|
||||||
|
Profil war anschließend gesund. Die frühere IQ3_XXS-GGUF wurde erst nach diesen
|
||||||
|
Prüfungen entfernt; die JSON-Ergebnisse liegen auf Athena unter
|
||||||
|
`/data/model-benchmarks/gsq-rco-iq3s-ab-20260908/`.
|
||||||
|
|
||||||
|
## Profilweiter A/B-Härtetest am 08.09.2026
|
||||||
|
|
||||||
|
Ein zweiter Test verglich GSQ-RCO IQ3_S mit den jeweils heute verwendeten
|
||||||
|
Q4-Modellen unter den echten Kontext-, GPU-, MTP- und Batch-Einstellungen der
|
||||||
|
Profile. Medium und Large luden dabei auch den Vision-Projektor auf der RTX
|
||||||
|
3060; der dort bereits laufende TTS-Dienst blieb unangetastet. Alle acht
|
||||||
|
Varianten fanden drei synthetische Nadeln bei 70 Prozent des jeweiligen
|
||||||
|
Kontextfensters.
|
||||||
|
|
||||||
|
| Profil | Q4 kurzer Prefill | IQ3_S kurzer Prefill | Delta | Q4 Decode | IQ3_S Decode | Delta | Q4 Lang-Prefill | IQ3_S Lang-Prefill | Delta | Q4 Lang-Decode | IQ3_S Lang-Decode | Delta |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Fast 76,8K | 938,2 | 860,5 | -8,3 % | 115,3 | 109,2 | -5,2 % | 816,7 | 757,4 | -7,3 % | 74,7 | 76,4 | +2,3 % |
|
||||||
|
| Medium 160K | 1.449,7 | 1.342,3 | -7,4 % | 104,0 | 86,9 | -16,5 % | 948,3 | 897,3 | -5,4 % | 56,3 | 48,9 | -13,1 % |
|
||||||
|
| Large 192K | 1.456,5 | 1.342,6 | -7,8 % | 104,3 | 86,9 | -16,7 % | 849,5 | 808,9 | -4,8 % | 52,2 | 45,5 | -12,8 % |
|
||||||
|
| Ultra 262K | 1.728,3 | 1.288,2 | -25,5 % | 83,8 | 73,5 | -12,3 % | 808,2 | 677,8 | -16,1 % | 33,3 | 32,0 | -4,0 % |
|
||||||
|
|
||||||
|
Alle Geschwindigkeiten sind Token/s. `Fast` vergleicht das produktive
|
||||||
|
IQ4-MIX mit IQ3_S; die übrigen Profile vergleichen IQ4_XS Pure mit IQ3_S.
|
||||||
|
Die langen Prompts enthielten rund 53,8K, 112K, 134,5K beziehungsweise 183,6K
|
||||||
|
synthetische Token.
|
||||||
|
|
||||||
|
Der komplexere Qualitätstest bestand aus neun deutschsprachigen Aufgaben zu
|
||||||
|
Logik, evidenzgebundener Diagnose, nebenläufigem Python, Kapazitätsplanung,
|
||||||
|
Prompt-Injection, Laufzeit- gegenüber Konfigurationszustand und sicherem
|
||||||
|
Adminverhalten sowie einem nativen Tool-Call. Acht Aufgaben waren inhaltlich
|
||||||
|
gleichwertig; beide Modelle hatten beim nebenläufigen Python-Code denselben
|
||||||
|
subtilen Restfehler. Bei der Kapazitätsplanung ermittelten beide intern korrekt,
|
||||||
|
dass die Migration unmöglich ist. Beide erreichten jedoch das 4K-Ausgabelimit:
|
||||||
|
Q4 gab die Schlussfolgerung und fast den ganzen Beweis sichtbar aus, IQ3_S
|
||||||
|
verbrauchte das Limit vollständig im Reasoning und lieferte keinen sichtbaren
|
||||||
|
Antworttext. Beide nativen Tool-Calls waren korrekt.
|
||||||
|
|
||||||
|
Über alle neun Aufgaben benötigte Q4 223,0 Sekunden und IQ3_S 278,9 Sekunden;
|
||||||
|
IQ3_S war damit 25,0 Prozent länger beschäftigt. Zusammen mit der überwiegend
|
||||||
|
niedrigeren Inferenzgeschwindigkeit ist kein profilweiter Vorteil belegt.
|
||||||
|
|
||||||
|
Entscheidung: Die produktiven Q4-Profile werden nicht durch IQ3_S ersetzt und
|
||||||
|
es werden keine vollständigen Q3-Doppelprofile angelegt. `beta1` bleibt als
|
||||||
|
gezielter 112K-Versuch erhalten: Dort passt das gesamte Textmodell auf die RTX
|
||||||
|
5080, während der Projektor auf der RTX 3060 liegt. Dieser besondere
|
||||||
|
Platzierungsvorteil gilt nicht automatisch für die größeren Profile.
|
||||||
|
|
||||||
|
Die vollständigen JSON-Ergebnisse liegen auf Athena unter
|
||||||
|
`/data/model-benchmarks/gsq-rco-iq3s-ab-v2-20260908/`.
|
||||||
|
|
||||||
|
## Nachtest mit maximaler RTX-5080-Belegung am 08.09.2026
|
||||||
|
|
||||||
|
Der vorige Vergleich übernahm absichtlich die produktiven Q4-Tensor-Splits.
|
||||||
|
Dadurch nutzte IQ3_S seinen geringeren Platzbedarf nicht aus. In einem weiteren
|
||||||
|
reinen Geschwindigkeitstest wurde deshalb pro Profil der größtmögliche unter
|
||||||
|
echter Last stabile Anteil auf der RTX 5080 gesucht. TTS blieb auf der RTX 3060
|
||||||
|
geladen. Ein Split galt erst dann als stabil, wenn Modellstart, kurzer Test und
|
||||||
|
ein Prompt mit rund 70 Prozent des Kontextfensters vollständig durchliefen.
|
||||||
|
|
||||||
|
| Profil | stabiler IQ3_S-Split 5080:3060 | kurzer Prefill vs. Q4 | Decode vs. Q4 | Lang-Prefill vs. Q4 | Lang-Decode vs. Q4 |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Medium 160K | 96:4 | +1,1 % | -8,2 % | +2,9 % | -2,8 % |
|
||||||
|
| Large 192K | 96:4 | +0,8 % | -8,3 % | +1,9 % | -2,1 % |
|
||||||
|
| Ultra 262K | 88:12 | -20,1 % | -4,5 % | -12,4 % | +3,2 % |
|
||||||
|
|
||||||
|
Medium lief mit 96:4 stabil. 98:2 ließ sich zwar laden, stürzte jedoch beim
|
||||||
|
ersten langen Prompt ab; 99:1 scheiterte bereits beim Laden. Large lief mit
|
||||||
|
96:4 stabil, während 97:3 beim Laden des MTP-KV-Caches keinen ausreichenden
|
||||||
|
VRAM mehr hatte. Ultra lief mit 88:12 stabil. 92:8 und 90:10 ließen sich laden,
|
||||||
|
stürzten aber beim langen Prompt ab; 94:6 scheiterte bereits an den benötigten
|
||||||
|
Compute-Puffern. Die scheinbar nicht streng monotone Belegung entsteht durch
|
||||||
|
die diskrete Verteilung ganzer Tensoren beziehungsweise Layer und zusätzliche
|
||||||
|
KV-, MTP- und Compute-Puffer.
|
||||||
|
|
||||||
|
Alle drei stabilen Grenzläufe fanden erneut sämtliche drei Nadeln. Das stärkere
|
||||||
|
Ausreizen der RTX 5080 macht IQ3_S bei Medium und Large im Prefill knapp
|
||||||
|
schneller, beseitigt den Decode-Nachteil aber nicht. Bei Ultra steht einem
|
||||||
|
kleinen Vorteil von 3,2 Prozent im langen Decode ein deutlicher
|
||||||
|
Prompt-Verarbeitungsverlust gegenüber. Auch nach optimaler Platzierung ergibt
|
||||||
|
sich daher kein Geschwindigkeitsgrund, die produktiven Q4-Profile zu ersetzen.
|
||||||
|
Die optimierten JSON-Ergebnisse liegen im selben Benchmark-Verzeichnis und
|
||||||
|
tragen das Suffix `opt96-4` beziehungsweise `opt88-12`.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Bewertung lokaler Fotorestaurierung
|
||||||
|
|
||||||
|
Stand: 8. September 2026
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Athena betreibt derzeit **keinen separaten Fotorestaurationspfad**. Das
|
||||||
|
virtuelle Hermes-/Router-Modell `restauration`, der HYPIR-Worker und der dafür
|
||||||
|
angelegte Hermes-Skill wurden nach Ende-zu-Ende-Tests wieder entfernt.
|
||||||
|
|
||||||
|
Die normale Bildgenerierung und kreative Referenzbildbearbeitung mit
|
||||||
|
`FLUX.2-klein-9B-fp8-beta` bleiben davon unberührt. Sie sind jedoch kein Ersatz
|
||||||
|
für eine originalgetreue Restaurierung beschädigter oder stark unscharfer
|
||||||
|
Fotos.
|
||||||
|
|
||||||
|
## Getestete Ansätze
|
||||||
|
|
||||||
|
### HYPIR-SD2
|
||||||
|
|
||||||
|
HYPIR lief technisch als eigener Worker und war über den Athena-Router sowie
|
||||||
|
Hermes aufrufbar. Beim realen Testfoto wurden jedoch Strukturen geglättet oder
|
||||||
|
neu gezeichnet, statt vorhandene Details zuverlässig wiederherzustellen. Die
|
||||||
|
Identität und Geometrie kleiner Bildbereiche konnten driften. Das Ergebnis
|
||||||
|
erfüllte damit die Anforderung „gleiches Foto, nur sauberer und schärfer“
|
||||||
|
nicht.
|
||||||
|
|
||||||
|
### SeedVR2 7B FP8
|
||||||
|
|
||||||
|
SeedVR2 wurde isoliert auf Athena getestet, ohne es in Hermes oder den
|
||||||
|
produktiven Router einzubauen. Der Lauf bei 2048 × 1536 Pixeln war technisch
|
||||||
|
erfolgreich und bewahrte Komposition und Identität besser als HYPIR. Bei stark
|
||||||
|
verrauschtem und bewegungsunscharfem Ausgangsmaterial stellte das Modell aber
|
||||||
|
keine wesentlich brauchbareren Details her; Unschärfe und Rauschen blieben zu
|
||||||
|
großen Teilen bestehen.
|
||||||
|
|
||||||
|
## Konsequenz für die Architektur
|
||||||
|
|
||||||
|
- kein `restauration`-Modell in `/v1/models`
|
||||||
|
- kein Restaurationszweig im Profile Router
|
||||||
|
- kein `restoration-worker` in Docker Compose
|
||||||
|
- keine HYPIR- oder SeedVR2-Gewichte auf Athena
|
||||||
|
- kein `image-restoration`-Skill und kein Restaurationsmodell in Hermes
|
||||||
|
- Referenzbilder gehen weiterhin ausschließlich an FLUX und gelten als
|
||||||
|
kreative Bildbearbeitung
|
||||||
|
|
||||||
|
Ein neuer Restaurationspfad soll erst wieder aufgenommen werden, wenn ein
|
||||||
|
Kandidat am realen Testfoto einen klaren Qualitätsgewinn zeigt, Identität und
|
||||||
|
Geometrie zuverlässig bewahrt und auf Athenas RTX 5080/RTX 3060-Konfiguration
|
||||||
|
reproduzierbar läuft. Ein bloß technisch erfolgreicher Lauf reicht nicht.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Athena-Betriebsmodi
|
||||||
|
|
||||||
|
Athena besitzt acht gegenseitig exklusive Betriebsmodi:
|
||||||
|
|
||||||
|
- `llm`: ein llama.cpp-Profil und Qwen3-TTS laufen; Spezialdienste sind gestoppt.
|
||||||
|
- `music`: ACE-Step 1.5 XL-SFT läuft; alle LLM-, Bild-, TTS- und Separator-Worker sind gestoppt.
|
||||||
|
- `yue2`: YuE2-3B und die angepasste `YuE2_WebUI` laufen; alle anderen
|
||||||
|
GPU-Dienste einschließlich ACE-Step sind gestoppt.
|
||||||
|
- `separation`: BS-RoFormer und Demucs trennen Musikspuren; ClearVoice trennt
|
||||||
|
Sprache von Hintergrundgeräuschen. LLM, Bild, TTS und ACE-Step sind gestoppt.
|
||||||
|
- `voice`: OmniVoice erzeugt Sprache aus Text mit einer gewählten
|
||||||
|
Referenzstimme. LLM, Bild, TTS, ACE-Step und Separator sind gestoppt.
|
||||||
|
- `voicechange`: X-VC überträgt eine vorhandene Sprachaufnahme auf eine
|
||||||
|
Referenzstimme und bewahrt dabei Inhalt und Timing. Alle anderen
|
||||||
|
GPU-Dienste sind gestoppt.
|
||||||
|
- `applio`: Applio stellt RVC-Inferenz, Modellverwaltung und Training bereit.
|
||||||
|
Alle anderen GPU-Dienste sind gestoppt.
|
||||||
|
- `trellis`: TRELLIS.2 4B Q8 erzeugt über trellis.cpp aus einem Eingabebild ein
|
||||||
|
texturiertes GLB. Der Worker läuft ausschließlich auf der RTX 5080; alle
|
||||||
|
anderen GPU-Dienste sind gestoppt.
|
||||||
|
|
||||||
|
Die Zustandsmaschine lebt im Athena-Router. Das Dashboard und Chat-Clients wie
|
||||||
|
Hermes sind nur Bedienoberflächen derselben API. Der zuletzt aktive LLM-Modus
|
||||||
|
wird persistent gespeichert und beim Verlassen eines Spezialmodus wieder geladen.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
Im Athena-Dashboard stehen **LLM-Betrieb**, **ACE-Step Studio**, **YuE2 Studio**, **Audio trennen**,
|
||||||
|
**Voice Studio**, **X-VC**, **Applio / RVC** und **3D Studio** bereit. Im Musikmodus werden zwei Oberflächen angeboten:
|
||||||
|
|
||||||
|
- **Original UI · stabil** öffnet die zum laufenden ACE-Step-Image gehörende
|
||||||
|
Gradio-Oberfläche. Sie ist für Cover, Remix und erweiterte Workflows der
|
||||||
|
verbindliche Produktionspfad.
|
||||||
|
- **Community UI · experimentell** öffnet `fspecii/ace-step-ui`. Die
|
||||||
|
CPU-leichte React/Express-Anwendung hält Bibliothek, Playlists und
|
||||||
|
Einstellungen in `/data/music/ace-step-ui`. Sie verwendet die offizielle
|
||||||
|
`/release_task`-API mit benannten Parametern und ist damit unabhängig von der
|
||||||
|
Reihenfolge der Gradio-Felder. Normale Generierung funktioniert; Cover und
|
||||||
|
Remix gelten bis zu eigenen Ende-zu-Ende-Tests weiterhin als experimentell.
|
||||||
|
Vor dem Start zeigt sie die übertragenen Werte an. Referenzaudio beeinflusst
|
||||||
|
nur Klang und Produktion, während Quellaudio Melodie, Rhythmus und Akkorde
|
||||||
|
erhält.
|
||||||
|
|
||||||
|
Die Community-Oberfläche ist im WireGuard-Netz unter
|
||||||
|
`http://192.168.1.212:7861`, die originale Gradio-Oberfläche unter
|
||||||
|
`http://192.168.1.212:7862` erreichbar. Beide Host-Ports bleiben zusätzlich
|
||||||
|
auf `127.0.0.1` gebunden und werden auf der Universitäts-Schnittstelle nicht
|
||||||
|
veröffentlicht. `ace-step-ui` ist reproduzierbar auf Commit
|
||||||
|
`a1fdf91829ec6f7b98844f80e323529cd155dbf2` fixiert und greift intern über das
|
||||||
|
Docker-Netz `mike-ai-music` auf `http://music-worker:7860` zu.
|
||||||
|
|
||||||
|
Das getrennte **YuE2 Studio** ist über WireGuard unter
|
||||||
|
`http://192.168.1.212:8014` erreichbar. Es verwendet YuE2-3B und die auf einen
|
||||||
|
festen Commit gesetzte `YuE2_WebUI` von Ladypoly. Analyse/Remix über
|
||||||
|
SheetSage2, Score-Übernahme und freie Generierung bleiben damit unabhängig vom
|
||||||
|
ACE-Step-Stack. Der Container trägt das Router-Label
|
||||||
|
`com.mike-ai.music-worker=yue2` und hängt als `yue2-studio` im privaten
|
||||||
|
Frontend-Netz. Ein Moduswechsel stoppt ihn zuverlässig, bevor LLM,
|
||||||
|
Audio-Trenner oder ein anderer GPU-Dienst gestartet werden.
|
||||||
|
|
||||||
|
Im Trennmodus öffnet das Dashboard die private Athena-Oberfläche unter
|
||||||
|
`http://192.168.1.212:8007`. Sie nimmt WAV, FLAC, MP3, M4A und weitere
|
||||||
|
übliche Formate an. Gewählt wird die herauszulösende Quelle: Gesang,
|
||||||
|
Schlagzeug, Bass, Gitarre, Piano, Sonstiges oder gereinigte Sprache. Das ZIP enthält genau diese Zielspur und
|
||||||
|
eine zweite FLAC-Datei mit dem vollständigen Rest ohne die Zielspur. Gesang
|
||||||
|
nutzt BS-RoFormer Viperx 1297, Schlagzeug/Bass `htdemucs_ft` und
|
||||||
|
Gitarre/Piano/Sonstiges experimentell `htdemucs_6s`. „Sonstiges“ ist dessen
|
||||||
|
gemischter `other`-Stem (unter anderem Synthesizer, Streicher, Bläser und Effekte),
|
||||||
|
nicht eine reine Synthesizer-Spur. Sprache nutzt das 48-kHz-Modell
|
||||||
|
`MossFormer2_SE_48K`; der Download enthält `speech.flac` und
|
||||||
|
`hintergrund-ohne-sprache.flac`. Die Musiktrennung basiert auf
|
||||||
|
`audio-separator` 0.47.0. Die ältere API-Auswahl kompletter 2-/4-/6-Stem-Sätze
|
||||||
|
bleibt rückwärtskompatibel.
|
||||||
|
|
||||||
|
Das Voice Studio ist ausschließlich über den privaten WireGuard-Pfad unter
|
||||||
|
`http://192.168.1.212:8008` erreichbar. Referenzstimmen werden unter
|
||||||
|
`/data/voice/studio/profiles` gespeichert. Die Oberfläche verlangt vor dem
|
||||||
|
Speichern eine Bestätigung der Nutzungsberechtigung. OmniVoice gibt
|
||||||
|
unkomprimiertes WAV aus und erzeugt Sprache aus Text; es verarbeitet keine
|
||||||
|
bereits eingesprochene Quellaufnahme.
|
||||||
|
|
||||||
|
Der X-VC Voice Changer ist ausschließlich unter
|
||||||
|
`http://192.168.1.212:8009` erreichbar. Er nimmt eine Quellaufnahme und eine
|
||||||
|
Referenzstimme an. Die Oberfläche behält immer das native 16-kHz-PCM-WAV und
|
||||||
|
erzeugt auf Wunsch zusätzlich mit Resemble Enhance eine neural restaurierte
|
||||||
|
44,1-kHz-Fassung. Diese zweite Datei rekonstruiert fehlende Sprachbandbreite;
|
||||||
|
sie stellt keine im 16-kHz-Signal tatsächlich erhaltenen Originaldetails wieder
|
||||||
|
her und bleibt deshalb direkt mit dem nativen Ergebnis vergleichbar. Die dokumentierte Sprachbasis
|
||||||
|
des verwendeten GLM-4-Voice-Tokenizers ist Chinesisch und Englisch; Deutsch
|
||||||
|
bleibt deshalb bis zur Hörabnahme ein Qualitätstest und kein zugesagter
|
||||||
|
Produktionspfad. X-VC läuft ausschließlich auf der RTX 5080.
|
||||||
|
|
||||||
|
Applio ist unter `http://192.168.1.212:8011` erreichbar. Der RVC-Pfad besitzt
|
||||||
|
eine eigene Modellbibliothek, Inferenz und Training. Hochwertige Inferenz
|
||||||
|
benötigt zwingend ein zuvor importiertes oder trainiertes RVC-Stimmenmodell
|
||||||
|
(`.pth`, optional `.index`). Eine bloße Referenzaufnahme genügt bei Applio
|
||||||
|
nicht. Der Code ist auf Commit
|
||||||
|
`7fa68ec2166ab1331c539704159fa14901e94e5a` fixiert.
|
||||||
|
|
||||||
|
Das TRELLIS.2-3D-Studio ist unter `http://192.168.1.212:8013` erreichbar. Es
|
||||||
|
verwendet trellis.cpp 0.6.0 und die Q8-Variante von TRELLIS.2 4B. Das Modell
|
||||||
|
läuft ausschließlich auf der RTX 5080; `1024 · cascade`, automatische
|
||||||
|
Hintergrundentfernung und `xatlas` sind die empfohlenen Standardwerte. Die UI
|
||||||
|
exportiert GLB. Ein nachgelagerter STL-/3MF-Export ist noch nicht Bestandteil
|
||||||
|
der Oberfläche.
|
||||||
|
|
||||||
|
Hermes benötigt dafür kein Plugin. Exakt eingegebene Steuerbefehle werden vom
|
||||||
|
Router lokal beantwortet, auch wenn gerade kein LLM geladen ist:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/athena music
|
||||||
|
/athena yue2
|
||||||
|
/athena stems
|
||||||
|
/athena voice
|
||||||
|
/athena voicechange
|
||||||
|
/athena applio
|
||||||
|
/athena 3d
|
||||||
|
/athena trellis
|
||||||
|
/athena llm
|
||||||
|
/athena status
|
||||||
|
```
|
||||||
|
|
||||||
|
Die HTTP-Schnittstelle verwendet authentifizierte Requests:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /mode
|
||||||
|
POST /mode {"mode":"music"}
|
||||||
|
POST /mode {"mode":"yue2"}
|
||||||
|
POST /mode {"mode":"separation"}
|
||||||
|
POST /mode {"mode":"voice"}
|
||||||
|
POST /mode {"mode":"voicechange"}
|
||||||
|
POST /mode {"mode":"applio"}
|
||||||
|
POST /mode {"mode":"trellis"}
|
||||||
|
POST /mode {"mode":"llm"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Wechsel läuft asynchron. Fortschritt und Fehler stehen unter `mode` in
|
||||||
|
`GET /status`. Der Profile-Controller akzeptiert ausschließlich den mit
|
||||||
|
`com.mike-ai.music-worker=acestep` oder `com.mike-ai.music-worker=yue2` beziehungsweise
|
||||||
|
`com.mike-ai.stem-separator=bs-roformer` oder
|
||||||
|
`com.mike-ai.voice-worker=vevo2` beziehungsweise
|
||||||
|
`com.mike-ai.voice-change-worker=xvc` oder
|
||||||
|
`com.mike-ai.applio-worker=applio` oder
|
||||||
|
`com.mike-ai.trellis-worker=trellis2-q8` markierten Container. Freie
|
||||||
|
Container- oder Docker-Befehle werden nicht entgegengenommen.
|
||||||
|
|
||||||
|
## Wiederanlauf
|
||||||
|
|
||||||
|
Der Router speichert `mode`, `last_profile` und `return_profile` atomar. War
|
||||||
|
beim Router-Neustart ein Spezialmodus aktiv, startet er den passenden Worker erneut. Beim
|
||||||
|
Wechsel zurück wird das gespeicherte LLM-Profil semantisch auf Alias und
|
||||||
|
Kontextfenster geprüft, bevor Chat-Anfragen wieder freigegeben werden.
|
||||||
@@ -21,9 +21,12 @@ Full-context results with the selected settings:
|
|||||||
|
|
||||||
The larger logical batches 3072 and 4096 did not improve Medium at ubatch 128. The original one-slot benchmark selected 2048 / 128 at 90:10.
|
The larger logical batches 3072 and 4096 did not improve Medium at ubatch 128. The original one-slot benchmark selected 2048 / 128 at 90:10.
|
||||||
|
|
||||||
## Medium two-slot benchmark
|
## Historischer Medium-Zwei-Slot-Test
|
||||||
|
|
||||||
Medium now uses two parallel slots with unified KV, so both chats dynamically share one total 160K-token pool. The model weights remain loaded only once. To fit the additional scheduler buffers, the production GPU split is 85:15 while batch / ubatch remains 2048 / 128.
|
This was an A/B candidate, not the current production configuration. Production
|
||||||
|
was returned to **one slot** because concurrent Hermes requests did not behave
|
||||||
|
reliably enough. The 85:15 GPU split and batch / ubatch 2048 / 128 remain in
|
||||||
|
production because they also work with the single-slot profile.
|
||||||
|
|
||||||
Identical fresh 100,297-token prompt with a deterministic 256-token completion:
|
Identical fresh 100,297-token prompt with a deterministic 256-token completion:
|
||||||
|
|
||||||
|
|||||||
+198
-45
@@ -1,62 +1,215 @@
|
|||||||
# Backup und Wiederherstellung
|
# Backup und vollständige Wiederherstellung
|
||||||
|
|
||||||
## Athena
|
Athena besitzt zwei voneinander unabhängige Sicherungsebenen. Nur gemeinsam
|
||||||
|
decken sie Systemplatten-, Datenplatten- und Totalausfall ab.
|
||||||
|
|
||||||
`mike-ai-backup` erzeugt alle fünf Stunden ein Archiv unter
|
## Sicherungsebenen
|
||||||
`/data/docker-backups` und behält 14 Tage. Gesichert werden:
|
|
||||||
|
|
||||||
- `/etc/mike-ai` mit lokaler Konfiguration,
|
| Ebene | Ziel | Takt | Zweck |
|
||||||
- Router-Zustand und erzeugte Bilder,
|
|---|---|---:|---|
|
||||||
- Piper-Daten,
|
| Lokales Schnellbackup | `/data/docker-backups` | alle 5 Stunden | schneller Wiederaufbau, wenn nur die Systemplatte stirbt |
|
||||||
- der kanonische Stack als zusätzlicher Snapshot.
|
| Verschlüsseltes Disaster-Backup | externes Restic-Repository, bevorzugt Unraid | nachts | Wiederaufbau, wenn `/data` oder beide Platten sterben |
|
||||||
|
|
||||||
Nicht in das Archiv gehören die großen Modellgewichte unter `/data/models`.
|
Ein Backup, das ausschließlich auf `/data` liegt, schützt ausdrücklich nicht
|
||||||
Sie bleiben auf der Daten-SSD oder werden anhand der gepinnten Angaben in
|
vor dem Ausfall der Datenplatte.
|
||||||
`config/install.env.example` erneut geladen. Die Dashboard-Historie liegt
|
|
||||||
dauerhaft unter `/data/llama-dashboard`.
|
|
||||||
|
|
||||||
### Neuaufbau
|
### Lokales Schnellbackup
|
||||||
|
|
||||||
1. Debian installieren und `/data` wieder am bisherigen Pfad einhängen.
|
`mike-ai-backup` sichert:
|
||||||
2. Dieses Repository klonen.
|
|
||||||
3. Installationsdatei ausfüllen und Installation starten:
|
- `/etc/mike-ai`, einschließlich `install.env`, WireGuard und Geheimnissen,
|
||||||
|
- ganz `/opt/mike-ai`, einschließlich aller bereitgestellten Spezialprojekte,
|
||||||
|
- Router-Zustand und Router-Bilder,
|
||||||
|
- Portainer-Daten.
|
||||||
|
|
||||||
|
Das Whisper-Volume ist reproduzierbar und wird bei Bedarf erneut geladen.
|
||||||
|
Ein vorhandener Hugging-Face-Token wird als root-only
|
||||||
|
`/etc/mike-ai/huggingface-token` mitgesichert, damit auch zugriffsbeschränkte
|
||||||
|
FLUX-Gewichte nach einem Datenverlust automatisch erneut geladen werden
|
||||||
|
können. Er steht niemals im Git-Repository.
|
||||||
|
|
||||||
|
### Externes Disaster-Backup
|
||||||
|
|
||||||
|
`athena-disaster-backup.timer` startet nachts ein verschlüsseltes,
|
||||||
|
dedupliziertes Restic-Backup. Vor jedem Lauf erzeugt es ein konsistentes
|
||||||
|
Docker-Schnellbackup und nimmt dieses in den externen Snapshot auf. Gesichert
|
||||||
|
werden außerdem:
|
||||||
|
|
||||||
|
- `/etc/mike-ai` und `/opt/mike-ai`,
|
||||||
|
- eigene Stimmen, Applio-Datasets und Trainingsstände unter `/data/voice`,
|
||||||
|
- Musikprojekte und Ausgaben unter `/data/music`,
|
||||||
|
- Audio-Trennungen unter `/data/audio`,
|
||||||
|
- Dashboard-, Operator-, Benchmark- und Projektdaten.
|
||||||
|
|
||||||
|
### Aktuelle TRELLIS-Lücke
|
||||||
|
|
||||||
|
Das am 10.09.2026 ergänzte 3D-Studio speichert seine Ausgaben unter
|
||||||
|
`/data/trellis-studio/output`. Dieser Pfad ist im derzeit ausgerollten
|
||||||
|
Export- und Disaster-Backup **noch nicht enthalten**. Wichtige GLB-Dateien
|
||||||
|
müssen bis zur Erweiterung der Backup-Skripte zusätzlich extern gesichert
|
||||||
|
werden. Runtime und Q8-Gewichte sind erneut ladbar; die vom Benutzer erzeugten
|
||||||
|
GLB-Dateien sind es nicht. Der Live-Code unter `/opt/mike-ai/trellis-studio`
|
||||||
|
wird vom lokalen Schnellbackup über `/opt/mike-ai` erfasst.
|
||||||
|
|
||||||
|
Die rund 100 GB reproduzierbaren Modellgewichte unter `/data/models` werden
|
||||||
|
nicht extern dupliziert. Kerngewichte lädt `install.sh` anhand URL und SHA256
|
||||||
|
neu. Spezialmodelle laden ihre gepinnten Container beim ersten Start erneut.
|
||||||
|
|
||||||
|
### Herunterladbare Notfallpakete
|
||||||
|
|
||||||
|
Zusätzlich erzeugt `athena-export-backup.timer` alle fünf Stunden ein mit Age
|
||||||
|
verschlüsseltes Komplettpaket der unersetzlichen Daten unter
|
||||||
|
`/data/emergency-backups`. Das Dashboard zeigt die letzten fünf Generationen
|
||||||
|
mit Größe, SHA256-Prüfsumme und einem fortsetzbaren Download an. Enthalten sind
|
||||||
|
insbesondere Applio-Logs und -Checkpoints, Datasets, eigene Stimmen,
|
||||||
|
Musikprojekte, Audioergebnisse, Konfiguration, Docker-Zustand und sämtliche
|
||||||
|
bereitgestellten Quellstände. Erneut ladbare Modell- und Hugging-Face-Caches
|
||||||
|
sind ausgeschlossen.
|
||||||
|
|
||||||
|
Bei aktuellem Datenbestand ist mit ungefähr 16 bis 20 GB je Generation zu
|
||||||
|
rechnen. Fünf Generationen benötigen daher grob 80 bis 100 GB auf `/data`.
|
||||||
|
Diese Pakete schützen nur dann vor einem Datenplattenausfall, wenn mindestens
|
||||||
|
eine Generation tatsächlich auf einen anderen Rechner oder Datenträger
|
||||||
|
heruntergeladen wurde. Die Pakete auf `/data` selbst sterben mit `/data`.
|
||||||
|
|
||||||
|
Der zu `recovery.age-recipient` gehörende private Age-Schlüssel darf nicht auf
|
||||||
|
Athena verbleiben. Ohne ihn können die Pakete absichtlich nicht entschlüsselt
|
||||||
|
werden.
|
||||||
|
|
||||||
|
## Einmalige Einrichtung des externen Backups
|
||||||
|
|
||||||
|
1. Ein physisch anderes Backupziel bereitstellen, vorzugsweise einen
|
||||||
|
ausschließlich über WireGuard erreichbaren Unraid-Share, und zum Beispiel
|
||||||
|
unter `/mnt/athena-offsite` einhängen.
|
||||||
|
2. Eine starke Restic-Passphrase erzeugen und **zusätzlich außerhalb Athenas**
|
||||||
|
in einem Passwortmanager oder auf einem Recovery-USB verwahren.
|
||||||
|
3. Konfiguration anlegen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./install.sh --config /root/mike-ai-install.env
|
cp config/disaster-backup.env.example /etc/mike-ai/disaster-backup.env
|
||||||
|
chmod 600 /etc/mike-ai/disaster-backup.env
|
||||||
|
# Repository, Mountpoint und Passwortdatei eintragen; danach:
|
||||||
|
sed -i 's/^DISASTER_BACKUP_ENABLED=false/DISASTER_BACKUP_ENABLED=true/' \
|
||||||
|
/etc/mike-ai/disaster-backup.env
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Letztes Datenarchiv einspielen:
|
4. Ersten Lauf und Snapshot prüfen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./restore.sh /data/docker-backups/athena-latest.tar.gz
|
systemctl start athena-disaster-backup.service
|
||||||
sudo ./smoke-test.sh
|
journalctl -u athena-disaster-backup.service --no-pager
|
||||||
|
restic snapshots --tag athena-disaster
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Restore verändert weder SSH noch LAN, WireGuard, Kernel, Partitionen oder
|
Die externe Recovery-Konfiguration und die Passphrase bilden den kleinen
|
||||||
Mounts.
|
Recovery-Schlüssel. Eine Kopie davon muss außerhalb beider Athena-Platten
|
||||||
|
liegen. Ohne extern erreichbares Repository und dessen Schlüssel ist ein
|
||||||
|
Totalausfall mathematisch nicht wiederherstellbar.
|
||||||
|
|
||||||
|
## Gemeinsame Voraussetzung aller drei Fälle
|
||||||
|
|
||||||
|
Debian 13 ist frisch beziehungsweise weiterhin vorhanden. Die korrekte
|
||||||
|
Datenpartition ist formatiert und als **eigener Mountpoint** `/data`
|
||||||
|
eingehängt. `disaster-recovery.sh` partitioniert und formatiert absichtlich
|
||||||
|
nichts und bricht ab, wenn `/data` nur ein Verzeichnis auf der Systemplatte
|
||||||
|
ist. Dadurch kann es nicht versehentlich die falsche Platte überschreiben.
|
||||||
|
|
||||||
|
Der Installer darf einen kontrollierten Neustart für NVIDIA-Treiber oder die
|
||||||
|
stabile Netzwerkschnittstelle verlangen. Das Recovery-Skript startet Athena
|
||||||
|
niemals selbst neu. Nach dem manuellen Neustart wird derselbe Befehl erneut
|
||||||
|
ausgeführt; alle Schritte sind idempotent.
|
||||||
|
|
||||||
|
## Fall 1: Systemplatte defekt, Datenplatte erhalten
|
||||||
|
|
||||||
|
Nach Debian-Installation und Einhängen der alten `/data`-Platte:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./disaster-recovery.sh --scenario system \
|
||||||
|
--archive /data/docker-backups/athena-latest.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript birgt Konfiguration und sämtliche `/opt/mike-ai`-Projekte aus dem
|
||||||
|
lokalen Archiv, installiert Docker/NVIDIA, verwendet die vorhandenen Modelle,
|
||||||
|
stellt die Docker-Volumes wieder her, baut Spezialcontainer und führt den
|
||||||
|
Smoke-Test aus.
|
||||||
|
|
||||||
|
Ältere Archive vor Einführung von `/etc/mike-ai/install.env` bleiben lesbar.
|
||||||
|
Bei einem solchen Archiv muss die Installationsdatei einmal separat angegeben
|
||||||
|
werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./disaster-recovery.sh --scenario system \
|
||||||
|
--archive /data/docker-backups/athena-latest.tar.gz \
|
||||||
|
--install-config /root/mike-ai-install.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fall 2: Datenplatte defekt, Systemplatte erhalten
|
||||||
|
|
||||||
|
Neue Datenpartition unter `/data` einhängen und den extern aufbewahrten
|
||||||
|
Recovery-Schlüssel bereitstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./disaster-recovery.sh --scenario data \
|
||||||
|
--config /root/athena-recovery.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Eigene Daten und der letzte Docker-Zustand kommen aus Restic. Modellgewichte
|
||||||
|
werden anschließend automatisch neu geladen. Je nach Internetverbindung ist
|
||||||
|
dies der längste Teil der Wiederherstellung.
|
||||||
|
|
||||||
|
## Fall 3: Beide Platten defekt
|
||||||
|
|
||||||
|
Debian auf der neuen Systemplatte installieren, neue Datenpartition als
|
||||||
|
`/data` einhängen, dieses Git-Repository klonen und den externen
|
||||||
|
Recovery-Schlüssel bereitstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./disaster-recovery.sh --scenario all \
|
||||||
|
--config /root/athena-recovery.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Der externe Snapshot liefert Installationskonfiguration, Schlüssel,
|
||||||
|
Anwendungsquellen, Spezial-UIs, eigene Daten und Docker-Zustand. Danach werden
|
||||||
|
Pakete, Images und Modellgewichte reproduzierbar neu aufgebaut.
|
||||||
|
|
||||||
|
Alternativ kann ein zuvor aus dem Dashboard heruntergeladenes Notfallpaket
|
||||||
|
direkt verwendet werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./disaster-recovery.sh --scenario all \
|
||||||
|
--portable /mnt/usb/athena-portable-2026-09-10T15-00-00Z.tar.zst.age \
|
||||||
|
--identity /mnt/usb/athena-recovery-key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ergebnis und Sicherheitsverhalten
|
||||||
|
|
||||||
|
Nach erfolgreichem Lauf gilt:
|
||||||
|
|
||||||
|
- Kernstack und Dashboard laufen,
|
||||||
|
- Medium ist das aktive LLM-Standardprofil,
|
||||||
|
- Spezialcontainer und ihre Oberflächen sind gebaut beziehungsweise erstellt,
|
||||||
|
- GPU-intensive Spezialworker bleiben gestoppt,
|
||||||
|
- keine automatische Umschaltung in Musik-, Bild-, Voice- oder Applio-Modus,
|
||||||
|
- `smoke-test.sh` hat den Kern geprüft.
|
||||||
|
|
||||||
|
Erst danach wird der gewünschte Spezialmodus über das Dashboard aktiviert.
|
||||||
|
|
||||||
|
## Regelmäßige Prüfung
|
||||||
|
|
||||||
|
Mindestens vierteljährlich einen Restore in eine leere Test-VM beziehungsweise
|
||||||
|
auf Testdatenträger durchführen. Ein grünes Backup-Log beweist nur, dass Daten
|
||||||
|
geschrieben wurden; erst ein Restore-Test beweist Wiederherstellbarkeit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status athena-disaster-backup.timer
|
||||||
|
journalctl -u athena-disaster-backup.service --since '2 days ago'
|
||||||
|
restic snapshots --tag athena-disaster
|
||||||
|
sudo ./restore.sh --check /data/docker-backups/athena-latest.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
## Unraid
|
## Unraid
|
||||||
|
|
||||||
Hermes und die Fach-MCPs sind kein Bestandteil des Athena-Backups. Sie werden
|
Hermes und die Fach-MCPs laufen auf Unraid und sind kein Bestandteil des
|
||||||
durch das vorhandene Unraid-Appdata-Backup gesichert:
|
Athena-Restores. Sie werden weiterhin über das Unraid-Appdata-Backup gesichert.
|
||||||
|
Das Athena-Disaster-Repository muss auf einem anderen Datenträger beziehungsweise
|
||||||
- `/mnt/nvme-storage/appdata/Hermes-Agent`
|
Storage-Pool als das zu schützende Athena-System liegen.
|
||||||
- die jeweiligen Appdata-Verzeichnisse der MCP-Container
|
|
||||||
- DockerMan-Templates unter
|
|
||||||
`/boot/config/plugins/dockerMan/templates-user/`
|
|
||||||
|
|
||||||
Container-Images stammen aus den dokumentierten Registries beziehungsweise den
|
|
||||||
eigenen Gitea-Repositories. Damit besteht die Wiederherstellung aus
|
|
||||||
Appdata-Restore plus Neuerstellung über die jeweilige Template-XML.
|
|
||||||
|
|
||||||
## Kontrolle
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file /etc/mike-ai/stack.env ps
|
|
||||||
test -s /data/docker-backups/athena-latest.tar.gz
|
|
||||||
curl -fsS http://192.168.1.212:8099/health
|
|
||||||
sudo ./smoke-test.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Anschließend einen Hermes-Chat, einen Router-Aufruf und je eine kleine
|
|
||||||
read-only-Abfrage der benötigten MCPs testen.
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Roadmap fuer spezialisierte lokale KI-Dienste
|
||||||
|
|
||||||
|
Stand: 10. September 2026
|
||||||
|
|
||||||
|
Diese Liste sammelt Nischenmodelle, die wir auf Athena nacheinander testen.
|
||||||
|
Ein Eintrag ist erst produktiv, wenn er auf der realen Hardware abgenommen und
|
||||||
|
im zentralen Register `TESTED_MODELS.md` dokumentiert wurde.
|
||||||
|
|
||||||
|
| Prioritaet | Aufgabe | Kandidat | Geplanter Betrieb | Status |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | Musik erzeugen und bearbeiten | `ACE-Step 1.5 XL SFT` mit `acestep-5Hz-lm-1.7B` | exklusives On-Demand-Profil auf der RTX 5080; CPU-Offload; Qwen, Vision und TTS werden waehrenddessen entladen | **integriert; Klangabnahme laeuft** |
|
||||||
|
| 2 | Gesang und Instrumente trennen | BS-RoFormer Viperx 1297, `ep_317` | exklusiver Audio-Worker auf der RTX 5080; FLAC-Ausgabe; eigener Dashboard-Modus | **integriert; Qualitätstest läuft** |
|
||||||
|
| 3 | Voice Cloning | vorhandenes `Qwen3-TTS-12Hz-1.7B-Base` | bestehender TTS-Worker auf der RTX 3060; zunaechst den eingebauten 3-Sekunden-Klonpfad freilegen | offen |
|
||||||
|
| 4 | Objekte lokalisieren und zaehlen | Grounding DINO oder RF-DETR | optionaler Vision-Worker; normales Erkennen bleibt beim vorhandenen Qwen-Vision-Projektor | offen |
|
||||||
|
| 5 | Bildort schaetzen | GeoAgent 8B | exklusives Vision-Profil; Ergebnis nur als Wahrscheinlichkeitsrangliste | offen |
|
||||||
|
| 6 | Eigene Orte/Bilder wiederfinden | AnyLoc oder GME-Qwen2-VL-7B | Embedding-Index mit eigener Referenzdatenbank | offen |
|
||||||
|
| 7 | Bild zu texturiertem 3D-Modell | TRELLIS.2 4B Q8 über trellis.cpp 0.6.0 | exklusiver Worker auf RTX 5080; GLB; Standard 1024 | **integriert; technischer Ende-zu-Ende-Test bestanden** |
|
||||||
|
|
||||||
|
## Grundsaetze
|
||||||
|
|
||||||
|
- Qualitaet geht vor Dauerbetrieb: schwere Spezialmodelle duerfen die regulaeren
|
||||||
|
Profile voruebergehend entladen.
|
||||||
|
- Die RTX 5080 und RTX 3060 besitzen zusammen 28 GiB physischen VRAM, bilden
|
||||||
|
aber keinen gemeinsamen Speicherpool. Mehrkartenbetrieb muss vom jeweiligen
|
||||||
|
Modell beziehungsweise Backend ausdruecklich unterstuetzt werden.
|
||||||
|
- Jeder Test bleibt isoliert und entfernbar. Abgelehnte Images, Gewichte, Caches
|
||||||
|
und Integrationsreste werden nach der Dokumentation entfernt.
|
||||||
|
- Neue Kandidaten werden vor dem Download mit `TESTED_MODELS.md` abgeglichen.
|
||||||
|
|
||||||
|
## Erster Test: ACE-Step 1.5 XL SFT
|
||||||
|
|
||||||
|
Der erste Durchlauf nutzt nur die RTX 5080. Laut offiziellem Projekt benoetigt
|
||||||
|
XL mindestens 12 GiB mit Offload und empfiehlt mindestens 20 GiB ohne Offload.
|
||||||
|
Auf der 16-GiB-5080 wird deshalb der offiziell vorgesehene Offload-Pfad mit dem
|
||||||
|
1,7B-Musikplaner getestet. Die 3060 bleibt zunaechst frei; eine Verteilung ueber
|
||||||
|
beide Karten wird erst erwogen, wenn ACE-Step dafuer einen belastbaren
|
||||||
|
Inferenzpfad anbietet.
|
||||||
|
|
||||||
|
Abnahmekriterien:
|
||||||
|
|
||||||
|
1. Dienst startet reproduzierbar und belegt keine GPU im Ruhezustand.
|
||||||
|
2. Ein 30-Sekunden-Stueck wird ohne OOM erzeugt.
|
||||||
|
3. Laufzeit, Spitzen-VRAM, RAM-Nutzung und Ausgabedatei werden protokolliert.
|
||||||
|
4. Danach werden Ultra und TTS wiederhergestellt.
|
||||||
|
5. Erst nach bestandener Abnahme folgt die Hermes-Integration.
|
||||||
|
|
||||||
|
Erster Messlauf am 8. September 2026: Ein 30-Sekunden-Instrumental wurde in
|
||||||
|
15,39 Sekunden erzeugt (LM 8,00 s, DiT 7,39 s, MP3-Encoding 0,82 s). Die
|
||||||
|
gemeldete maximale CUDA-Allokation lag bei 9,38 GiB. Es gab weder OOM noch
|
||||||
|
CUDA-Fehler. Der technische Test ist damit bestanden; die subjektive
|
||||||
|
Die originale ACE-Step-Gradio-Oberflaeche ist der stabile Produktionspfad. Die
|
||||||
|
persistente `fspecii/ace-step-ui`-Oberflaeche bleibt bis zur Abnahme aller
|
||||||
|
Audio-zu-Audio-Modi experimentell. Am 10. September 2026 wurde der zerbrechliche
|
||||||
|
Aufruf der positionsabhaengigen `/generation_wrapper`-Schnittstelle entfernt.
|
||||||
|
Die Community-UI nutzt nun `/release_task` mit benannten Parametern; das
|
||||||
|
versionierte Worker-Derivat reicht dabei auch Referenz-/Quellaudio, Cover-
|
||||||
|
Staerke, Thinking, AI Enhance und die XL-SFT-Werte weiter.
|
||||||
|
Ein zehnsekündiger FLAC-Textauftrag lief am 8. September 2026 erfolgreich durch
|
||||||
|
UI, Express-Backend und Gradio-API und wurde in der persistenten Bibliothek
|
||||||
|
gespeichert; dieser Test belegt Cover und Remix ausdrücklich noch nicht.
|
||||||
|
|
||||||
|
Offizielle Referenzen: [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5)
|
||||||
|
und [REST-API](https://github.com/ace-step/ACE-Step-1.5/blob/main/docs/en/API.md).
|
||||||
@@ -7,7 +7,7 @@ Standardprofil: **medium** · globales Ausgabelimit: **8192 Token**
|
|||||||
| Profil | API-Alias | Gesamtkontext | Slots | Modell | GPU-Verteilung | Vision | MTP |
|
| Profil | API-Alias | Gesamtkontext | Slots | Modell | GPU-Verteilung | Vision | MTP |
|
||||||
|---|---|---:|---:|---|---|---|---:|
|
|---|---|---:|---:|---|---|---|---:|
|
||||||
| fast | `qwen-fast` | 76,800 | 1 | Qwen3.8-27B IQ4 Mix | 5080 only | ja | 2 |
|
| fast | `qwen-fast` | 76,800 | 1 | Qwen3.8-27B IQ4 Mix | 5080 only | ja | 2 |
|
||||||
| medium | `qwen-medium` | 160,000 | 2 | Qwen3.8-27B IQ4 XS Pure | 85:15 | ja | 3 |
|
| medium | `qwen-medium` | 160,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 85:15 | ja | 3 |
|
||||||
| large | `qwen-large` | 192,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 86:14 | ja | 3 |
|
| large | `qwen-large` | 192,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 86:14 | ja | 3 |
|
||||||
| ultra | `qwen-ultra` | 262,144 | 1 | Qwen3.8-27B IQ4 XS Pure | 80:20 | nein | 2 |
|
| ultra | `qwen-ultra` | 262,144 | 1 | Qwen3.8-27B IQ4 XS Pure | 80:20 | nein | 2 |
|
||||||
| uncensored | `qwen-uncensored` | 80,000 | 1 | Qwen3.8-27B Abliterated Q4_K_M | 90:10 | ja | 2 |
|
| uncensored | `qwen-uncensored` | 80,000 | 1 | Qwen3.8-27B Abliterated Q4_K_M | 90:10 | ja | 2 |
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Register getesteter Modelle
|
||||||
|
|
||||||
|
Stand: 10. September 2026
|
||||||
|
|
||||||
|
Dieses Dokument ist die zentrale Sperrliste gegen doppelte Modelltests. Vor
|
||||||
|
jedem Download müssen Repository, Dateiname, Basismodell, Fine-Tune und
|
||||||
|
Quantisierung hier geprüft werden. Unterschiedliche Quantisierungen desselben
|
||||||
|
Basismodells gelten als eigene Kandidaten.
|
||||||
|
|
||||||
|
Statuswerte:
|
||||||
|
|
||||||
|
- **produktiv**: wird von mindestens einem regulären Profil verwendet
|
||||||
|
- **Beta**: bleibt gezielt verfügbar, ersetzt aber nicht den Standard
|
||||||
|
- **verworfen**: getestet und ohne ausreichenden Gesamtvorteil
|
||||||
|
- **ersetzt**: früher genutzt oder getestet, inzwischen abgelöst
|
||||||
|
- **unvollständig**: Artefakt vorbereitet, aber kein belastbarer Abnahmetest
|
||||||
|
|
||||||
|
## Textmodelle auf Athena
|
||||||
|
|
||||||
|
| Datum | Exaktes Modell beziehungsweise Artefakt | Kontext im Test | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---:|---|---|---|
|
||||||
|
| 22.08.2026 | `jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF` / `qwen3.8-27b-IQ4_XS-pure.gguf` | 160K–262K | beste ausgewogene Q4-Referenz; Langkontext, Tool-Call und Vision geprüft | **produktiv** für Medium, Large und Ultra | `benchmarks/qwen38-final-pre-move-20260822/` |
|
||||||
|
| 22.08.2026 | `vmarcelo/Qwen3.8-27B-MIX_GGUF` / `Qwen3.8-27B-IQ4-MIX.gguf` | 76,8K | schnellstes vollständig auf der RTX 5080 liegendes Q4-Profil | **produktiv** für Fast | `benchmarks/qwen38-final-pre-move-20260822/` |
|
||||||
|
| 22.08.2026 | Qwen3.8-27B NVFP4 `Q4_K_M` mit eingebettetem beziehungsweise separatem MTP | 72K | eingebettete Variante scheiterte beim Laden; Split-MTP lief, bot aber keinen ausreichenden Vorteil | **verworfen** | `benchmarks/qwen38-final-pre-move-20260822/qwen38-final-acceptance-20260822/` |
|
||||||
|
| 22.08.2026 | `Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF` / `Q4_K_M` | 80K | stabiler Spezialpfad mit Vision und MTP2 | **produktiv** für Uncensored | `benchmarks/qwen38-final-pre-move-20260822/qwen38-abliterated-final-20260822/` |
|
||||||
|
| 01.09.2026 | `peculiar-ragdoll/Dirk-Qwen3.8-27B-GGUF` / `UD-Q4_K_XL` | 80K–262K | korrekt und teils knapper, bei 160K aber 29–38 % langsamer im Decode als Pure | **verworfen** | [DIRK_QWEN38_AB_20260901.md](DIRK_QWEN38_AB_20260901.md) |
|
||||||
|
| 04.09.2026 | ISTA-DASLab Qwen3.8-27B GSQ-RCO `IQ3_XXS-MTP` | bis 196.608 | sehr platzsparend und bis 196.608 technisch lauffähig; später durch IQ3_S ersetzt | **ersetzt** | [GSQ_RCO_BETA1_20260904.md](GSQ_RCO_BETA1_20260904.md) |
|
||||||
|
| 07.09.2026 | `Jackrong/Qwopus3.8-27B-Flash-GGUF` / `Qwopus3.8-27B-Flash-MTP-IQ4_XS.gguf` | 160K | Recall 3/3; Decode 87,2 statt 105,3 Token/s, Lang-Decode 51,0 statt 56,7 Token/s; kein Gesamtvorteil | **verworfen** | Athena: `/data/benchmarks/qwen38-ab-20260907/` |
|
||||||
|
| 07.09.2026 | `bartowski/Qwen3.8-27B-GGUF` / `Qwen3.8-27B-IQ4_XS.gguf` | 160K | Recall 3/3; Decode 90,4 statt 105,3 Token/s, Lang-Decode 51,9 statt 56,7 Token/s; kein Gesamtvorteil | **verworfen** | Athena: `/data/benchmarks/qwen38-ab-20260907/` |
|
||||||
|
| 08.09.2026 | `ISTA-DASLab/Qwen3.8-27B-GSQ-RCO-GGUF` / `IQ3_S-MTP` | 76,8K–262K | Qualität im lokalen Test praktisch gleich, trotz optimierter GPU-Splits überwiegend langsamer als Q4 | **entfernt**; kein Ersatz für Q4 | [GSQ_RCO_BETA1_20260904.md](GSQ_RCO_BETA1_20260904.md) |
|
||||||
|
| 08.09.2026 | `Tiel-Coder-35B-A3B-UD-IQ4_XS.gguf` | 160K vorgesehen | Testcontainer und Gewichte vorhanden gewesen, aber kein versionierter, belastbarer Abnahmebericht | **unvollständig**; nicht als getesteter Sieger behandeln | kein Ergebnisartefakt vorhanden |
|
||||||
|
|
||||||
|
## Externe CPU-Helfermodelle
|
||||||
|
|
||||||
|
Diese Versuche liefen nicht als Athena-Hauptprofil, sind aber relevant für
|
||||||
|
Titelgenerierung und Kontextkompression in Hermes.
|
||||||
|
|
||||||
|
| Datum | Modell | Beobachtung | Entscheidung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 06.09.2026 | Ollama `qwen3:8b` | ungefähr 8,5–9,2 Token/s auf dem alten Dual-Xeon-Server | technisch brauchbar, aber für synchrone Hermes-Hilfsaufrufe langsam |
|
||||||
|
| 06.09.2026 | Ollama `gemma4:e4b` Q4 | ungefähr 4,7 Token/s auf dem HP EliteDesk, 10,2 auf dem alten Dual-Xeon und 15,1 auf dem neueren Proxmox-Host; mit Thinking liefen Hilfsaufrufe in Hermes in den 30-s-Timeout | nur mit `think:false` sinnvoll; nicht produktiv als Hermes-Auxiliary belegt |
|
||||||
|
|
||||||
|
## Bildmodelle und Restaurierung
|
||||||
|
|
||||||
|
| Datum | Modell | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| bis 07.09.2026 | FLUX.2 Klein 4B | funktional, aber schwächere räumliche und motivische Konsistenz | **ersetzt** durch 9B FP8 | [FLUX_9B_BETA.md](FLUX_9B_BETA.md) |
|
||||||
|
| 07.09.2026 | FLUX.2 Klein 9B FP8 | bessere Prompttreue; produktiver Zwei-GPU-Pfad, derzeit auf 1024 × 1024 begrenzt | **produktiv als Beta** | [FLUX_9B_BETA.md](FLUX_9B_BETA.md) |
|
||||||
|
| 08.09.2026 | HYPIR-SD2 | glättete oder erfand Details und veränderte kleine Strukturen | **verworfen** | [IMAGE_RESTORATION.md](IMAGE_RESTORATION.md) |
|
||||||
|
| 08.09.2026 | SeedVR2 7B FP8 | bewahrte Identität besser als HYPIR, brachte beim realen unscharfen Foto aber kaum nutzbare Details zurück | **verworfen** | [IMAGE_RESTORATION.md](IMAGE_RESTORATION.md) |
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
| Datum | Modell | Ergebnis | Status / Entscheidung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 05.09.2026 | Coqui XTTS v2 | deutsche Satzzeichen, Enden und Streaming-Chunks erzeugten Halluzinationen und unnatürliche Prosodie | **verworfen und entfernt** |
|
||||||
|
| 05.09.2026 | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | deutlich natürlichere deutsche Ausgabe ohne die XTTS-Endhalluzinationen | **produktiv** als einziges TTS-Backend |
|
||||||
|
| 06.–08.09.2026 | Whisper.cpp `large-v3-turbo` | lokaler TTS→STT-Rundlauf und OpenClaw-Transkription erfolgreich; später zugunsten des kleineren Laufzeitmodells entfernt | **ersetzt** |
|
||||||
|
| seit 03.09.2026 | Whisper.cpp `ggml-small` | tatsächlich im Compose-Stack und im laufenden Container verwendetes CPU-STT-Modell | **produktiv** |
|
||||||
|
| 09.09.2026 | `RMSnow/Vevo2`, Amphion `26f6883110181f1dbfe95c70a7c7dbaf4de5f42a` | Technik und Geschwindigkeit funktionierten, reale deutsche Sprachwandlung mit kurzer und langer Referenz war jedoch unverständlich, halluzinierend oder musikalisch | **qualitativ verworfen und entfernt**; Ergebnis bleibt hier dokumentiert, Images, Daten und altes Projekt wurden am 09.09. bereinigt |
|
||||||
|
| 09.09.2026 | `k2-fsa/OmniVoice` 0.2.1 | Offizielle Gradio-UI auf RTX 5080 gestartet; Modell plus Whisper-ASR belegen rund 3,7 GiB VRAM. `omnivoice-triton` 0.1.0 ist kompatibel im Image vorhanden, für den ersten Hörtest aber bewusst noch nicht aktiviert | **technischer Starttest bestanden**, Hörabnahme und Basis-vs.-Triton-Messung offen; Gewichte CC BY-NC und daher nur nichtkommerziell einsetzen |
|
||||||
|
| 09.09.2026 | `chenxie95/X-VC`, Code `49df8c591eafc48b096e466d96f9839f9c0dd739`, UI-Basis `d761cd6421e85376b2656dfefd8471d7f35a42be` | Offizielles Beispiel Ende-zu-Ende gewandelt: 5,20 s Audio in 1,07 s (RTF 0,21), gültiges 16-kHz-Mono-PCM-WAV; Modell belegt rund 2,9 GiB auf der RTX 5080. GLM-4-Voice-Tokenizer dokumentiert Chinesisch und Englisch | **technischer Start- und Konvertierungstest bestanden**; deutsche Hörabnahme offen |
|
||||||
|
| 09.09.2026 | Resemble Enhance 0.0.1, Modellrevision `4e3510ce4a8391159f665903544c5150bee7b2cb` | 14,56 s native X-VC-Ausgabe bei 16 kHz wurden auf der RTX 5080 in 3,55 s zu 44,1-kHz-PCM-WAV restauriert. 3,27 % der gemessenen Signalenergie lagen danach oberhalb 8 kHz; damit ist der Pfad keine bloße Neuabtastung. Wegen der alten Upstream-Pins läuft die reine Inferenz mit NumPy 1.26.4/SciPy 1.11.4 auf dem bestehenden Torch-2.8/CUDA-12.8-Unterbau | **technisch produktiv als optionaler A/B-Pfad**; Hörabnahme entscheidet, ob die rekonstruierten Höhen subjektiv besser oder künstlicher klingen |
|
||||||
|
| 09.09.2026 | `Plachtaa/seed-vc` V1, Code `51383efd921027683c89e5348211d93ff12ac2a8` | Technisch vollständig lauffähig: gepinntes CUDA-Image, persistente Gewichte und reale WAV-Konvertierung mit etwa 3,6 GiB VRAM. Im deutschen Hörtest erhielt die Ausgabe jedoch einen deutlich chinesischen Akzent | **qualitativ verworfen und vollständig entfernt**; nicht erneut für deutsche Sprachwandlung einplanen |
|
||||||
|
| 09.09.2026 | `IAHispano/Applio`, Code `7fa68ec2166ab1331c539704159fa14901e94e5a` | Gepinntes CUDA-12.8-fähiges Image auf RTX 5080 gestartet; vollständige Applio/RVC-Oberfläche antwortet und CUDA ist verfügbar. Rund 1,8 GiB Basisgewichte und die Konfiguration wurden persistent ausgelagert. Es ist kein Zielstimmenmodell installiert; Applio kann aus einer Referenzaufnahme allein kein Modell ableiten | **technischer Start- und Persistenztest bestanden**; Konvertierung erst nach Import oder Training einer `.pth`-Stimme möglich |
|
||||||
|
|
||||||
|
## 3D-Erzeugung
|
||||||
|
|
||||||
|
| Datum | Modell | Test | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 10.09.2026 | TRELLIS.2 4B Q8, zehn GGUF-Komponenten, trellis.cpp 0.6.0 | Bild-zu-3D bei 512, ausschließlich RTX 5080, Hintergrundentfernung `auto`, UV `xatlas` | HTTP 200 nach 54,2 s; gültiges GLB 2 mit 4,4 MB; Container und Browseroberfläche gesund | **technisch integriert**; 1024 ist der vorgesehene Qualitätsstandard, Druck- und subjektive Geometrieabnahme noch offen | Athena: `/opt/mike-ai/trellis-studio`, Gewichte: `/data/models/trellis2-q8` |
|
||||||
|
|
||||||
|
## Musikgenerierung
|
||||||
|
|
||||||
|
| Datum | Modell | Test | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 08.–10.09.2026 | `ACE-Step/acestep-v15-xl-sft` mit `acestep-5Hz-lm-1.7B`, offizielles ACE-Step-1.5-Image `sha256:95652cd780c78a1b1a7f6f0335530430f0ae53d96c7c12d59f9f39fa23d38567` | Mehrere Instrumentaltests bis 244 s; abschließender Kontrolllauf mit geladenem 1.7B-Planer, `thinking=True`, XL-SFT 4B, 80 Schritten, Guidance 8 und Shift 3 | technisch vollständig und schnell, aber wiederholt nur Geräusche/Krach oder musikalisch chaotische Ergebnisse; der letzte Lauf schließt einen bloß fehlenden Planer als Ursache aus | **qualitativ verworfen**; nicht als Qualitätslösung weiterverfolgen | Athena: `/data/music/acestep/`; [SPECIALIZED_MODEL_ROADMAP.md](SPECIALIZED_MODEL_ROADMAP.md) |
|
||||||
|
| 10.09.2026 | `HeartMuLa/HeartMuLa-oss-3B-happy-new-year` mit `HeartMuLa/HeartCodec-oss-20260123`, heartlib `3783bdb8441f2c298b1e64c8651173aac200361c` | Mehrere Instrumentalversuche mit offiziellen Samplingwerten; HeartMuLa BF16 auf RTX 5080, HeartCodec FP32 auf RTX 3060 | technisch stabil und schnell, klanglich jedoch fast so unbrauchbar wie ACE-Step: Fantasiesprache trotz Instrumentalwunsch und gravierende Missachtung der Synthwave-/Synthpop-Stilvorgabe. Die lokale Test-UI hatte zusätzlich einen nicht upstream dokumentierten `[Instrumental]`-Marker verwendet | **qualitativ verworfen und vollständig entfernt**; Container, Image, Gewichte und Ausgaben am 10.09.2026 gelöscht | diese Tabelle; keine Laufzeitreste auf Athena |
|
||||||
|
| 10.09.2026 | `m-a-p/YuE2-3B` mit `m-a-p/YuE2-Vae`, offizieller Release `yue2-v0.1.6` | Unquantisiertes BF16 ausschließlich auf RTX 5080; leere Lyrics, expliziter Instrumentalstil, 118 BPM, Seed 831001 und vollständige symbolische Planung | 189,5 s Musik in 83,8 s erzeugt; 48 kHz, Stereo, 24-Bit-FLAC, keine Kürzung und kein OOM. Der erste Hörtest war im deutlichen Gegensatz zu ACE-Step und HeartMuLa musikalisch überzeugend. Entstehung über neuen ABC-Plan, 4.739 semantische Tokens und neue akustische Latents verifiziert; keine mitgelieferte Demo-Datei | **technischer Test und erste Hörabnahme bestanden**; isolierter Playground bereit, breitere Stil-/Gesangsprüfung und spätere Routerentscheidung noch offen | [yue2-3b](../experiments/yue2-3b/README.md); Athena: `/data/music/yue2/instrumental_synthwave_control/` |
|
||||||
|
|
||||||
|
## Audio-Trennung
|
||||||
|
|
||||||
|
| Datum | Modell | Test | Ergebnis | Status / Entscheidung | Beleg |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 08.09.2026 | BS-RoFormer Viperx 1297, `model_bs_roformer_ep_317_sdr_12.9755.ckpt`, `audio-separator` 0.47.0 | 20-s-FLAC eines vorhandenen ACE-Step-Titels, RTX 5080, CUDA 12.8, ONNX Runtime GPU 1.22.0 | zwei gültige FLAC-Spuren mit jeweils exakt 20,0 s; Verarbeitung 19 s; Vocal-Datei 1,45 MB, Instrumental-Datei 3,84 MB | **technischer Ende-zu-Ende-Test bestanden**; Hörabnahme durch Nutzer offen | [bs-roformer-vocal-separation](../experiments/bs-roformer-vocal-separation/README.md) |
|
||||||
|
|
||||||
|
## Ablauf für zukünftige Kandidaten
|
||||||
|
|
||||||
|
1. Exakten Hugging-Face-/Ollama-Namen und Dateinamen in diesem Dokument suchen.
|
||||||
|
2. Bei einem Treffer zuerst den vorhandenen Beleg lesen; kein erneuter Download
|
||||||
|
ohne einen konkret neuen Grund wie Runtime, Quantisierung oder Hardware.
|
||||||
|
3. Neue Tests isoliert gegen das aktuelle Produktionsmodell mit identischem
|
||||||
|
Kontext, KV-Cache, MTP, Sampling und Promptset ausführen.
|
||||||
|
4. Unmittelbar danach hier Datum, exaktes Artefakt, Ergebnis, Entscheidung und
|
||||||
|
Pfad zum Detailbericht ergänzen.
|
||||||
|
5. Verworfene Gewichte nach gesichertem Ergebnis wieder löschen.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# ACE-Step 1.5 XL SFT: isolierter Athena-Test
|
||||||
|
|
||||||
|
Der GPU-Worker startet nur im Musikmodus und bindet seine rohe
|
||||||
|
Gradio-Oberflaeche nur an localhost. Die separate `fspecii/ace-step-ui`
|
||||||
|
bleibt als leichte React/Express-Oberflaeche aktiv; ihre SQLite-Datenbank,
|
||||||
|
Bibliothek und Uploads liegen persistent unter `/data/music/ace-step-ui`.
|
||||||
|
|
||||||
|
Das offizielle Image ist auf den am 8. September 2026 geladenen Digest
|
||||||
|
`sha256:95652cd780c78a1b1a7f6f0335530430f0ae53d96c7c12d59f9f39fa23d38567`
|
||||||
|
fixiert.
|
||||||
|
|
||||||
|
## Persistente Qualitaetsvorgaben
|
||||||
|
|
||||||
|
Die Weboberflaeche besitzt keine einzelne INI-Datei. Ihre Vorgaben kommen aus
|
||||||
|
Python-Modulen und teilweise aus dem Browser-`localStorage`. Deshalb bindet der
|
||||||
|
Compose-Dienst vier kleine, versionierte Overrides aus `./overrides` read-only
|
||||||
|
in den Container ein. Sie setzen fuer das XL-SFT-Modell:
|
||||||
|
|
||||||
|
- 80 DiT-Schritte, Guidance 8, Shift 3, ODE/Euler und CFG-Intervall 0 bis 1
|
||||||
|
- reine Stilreferenzen werden entsprechend der ACE-Step-API-Empfehlung automatisch mit Stärke 0,2 übertragen; Cover-/Quellaudio behält seine eigene Stärke
|
||||||
|
- ADG aus, keine benutzerdefinierten Timesteps
|
||||||
|
- FLAC als verlustfreie Standardausgabe
|
||||||
|
- 320 kbit/s als MP3-Ausweichwert
|
||||||
|
- Batchgroesse 1 fuer einen einzelnen Qualitaetslauf
|
||||||
|
- Normalisierung an bei -1 dB, kein Fade, Latent Shift 0, Latent Rescale 1
|
||||||
|
|
||||||
|
Die Preference-Schema-Version wurde auf 2 angehoben. Alte, im Browser
|
||||||
|
gespeicherte MP3/128-kbit/s-Werte werden dadurch einmalig verworfen; danach
|
||||||
|
bleiben bewusst vorgenommene Aenderungen wieder im jeweiligen Browser erhalten.
|
||||||
|
Beim Wechsel des gepinnten Image-Digests muessen die Overrides gegen die neue
|
||||||
|
Upstream-Fassung geprueft werden.
|
||||||
|
|
||||||
|
## Zwei Musikoberflaechen
|
||||||
|
|
||||||
|
Die originale Gradio-Oberflaeche aus demselben ACE-Step-Image ist der stabile
|
||||||
|
Produktionspfad fuer Simple, Custom, Cover, Remix und Repaint. Sie ist im
|
||||||
|
WireGuard-Netz unter `http://192.168.1.212:7862` erreichbar.
|
||||||
|
|
||||||
|
`music-ui` baut [fspecii/ace-step-ui](https://github.com/fspecii/ace-step-ui)
|
||||||
|
reproduzierbar von Commit `a1fdf91829ec6f7b98844f80e323529cd155dbf2`.
|
||||||
|
Die Community-Oberflaeche verwendet nicht mehr das positionsabhaengige
|
||||||
|
Gradio-Schema. Ihr Express-Dienst ruft die offizielle `/release_task`-API mit
|
||||||
|
benannten Feldern auf; das kleine Worker-Derivat erweitert diese Route um die
|
||||||
|
im installierten `GenerationParams` bereits vorhandenen Felder fuer Referenz-,
|
||||||
|
Quell- und Coveraudio sowie XL-SFT-Parameter. Athena-spezifisch sind ausserdem
|
||||||
|
die persistente Ablage, die XL-SFT-Anzeige und die gemeldeten Laufzeitlimits.
|
||||||
|
Schlaegt bei einer spaeteren Upstream-Fassung ein Patch-Anker fehl, bricht der
|
||||||
|
Image-Build ab. Die Community-UI unter `http://192.168.1.212:7861` ist bis zu
|
||||||
|
vollstaendigen Ende-zu-Ende-Tests von Cover und Remix als experimentell
|
||||||
|
gekennzeichnet.
|
||||||
|
|
||||||
|
Die Community-UI startet XL-SFT mit 80 Schritten, Guidance 8, Shift 3, FLAC
|
||||||
|
und aktivem Thinking ueber den 1,7B-Planer. `AI Enhance` wird als `use_format`
|
||||||
|
uebertragen. Vor jedem Auftrag zeigt sie die tatsaechlich gesendeten Parameter
|
||||||
|
und faengt offensichtliche Widersprueche ab. Referenzaudio steuert nur Klang
|
||||||
|
und Produktion; nur **Quellaudio / Cover** erhaelt Melodie, Rhythmus und
|
||||||
|
Akkorde.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
Vor dem Start muessen das aktive llama.cpp-Profil und Qwen3-TTS beendet sein.
|
||||||
|
Die RTX 5080 wird ueber ihre UUID exklusiv an den Container uebergeben.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ACESTEP_GPU_UUID="GPU-..."
|
||||||
|
export ACESTEP_UI_JWT_SECRET="$(openssl rand -hex 32)"
|
||||||
|
docker compose up -d music-ui
|
||||||
|
docker compose --profile music-test up -d music-worker
|
||||||
|
docker compose logs -f music-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ zum WireGuard-Zugriff lassen sich beide Oberflaechen per SSH-Tunnel
|
||||||
|
erreichen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 7861:127.0.0.1:7861 -L 7862:127.0.0.1:7862 root@athena.scc.kit.edu
|
||||||
|
```
|
||||||
|
|
||||||
|
`ace-step-ui` spricht den Worker ausschliesslich ueber das interne Docker-Netz
|
||||||
|
an. Dafuer startet ACE-Step mit aktivierten, benannten API-Endpunkten
|
||||||
|
(`--enable-api`).
|
||||||
|
|
||||||
|
## Beenden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile music-test stop music-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Befehl stoppt nur den GPU-Worker. Die Musikoberflaeche, ihre Bibliothek,
|
||||||
|
Caches, Modellgewichte und Ausgaben bleiben erhalten.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
FROM node:22-bookworm AS build
|
||||||
|
|
||||||
|
ARG ACE_STEP_UI_COMMIT
|
||||||
|
RUN test -n "$ACE_STEP_UI_COMMIT"
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN git clone https://github.com/fspecii/ace-step-ui.git /src \
|
||||||
|
&& cd /src \
|
||||||
|
&& git checkout --detach "$ACE_STEP_UI_COMMIT"
|
||||||
|
COPY patch-source.mjs /tmp/patch-source.mjs
|
||||||
|
RUN node /tmp/patch-source.mjs /src
|
||||||
|
RUN cd /src \
|
||||||
|
&& npm ci \
|
||||||
|
&& npm run build
|
||||||
|
RUN cd /src/server \
|
||||||
|
&& npm ci \
|
||||||
|
&& npm run build \
|
||||||
|
&& npm prune --omit=dev
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nginx curl ca-certificates ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /src/dist /usr/share/nginx/html
|
||||||
|
COPY --from=build /src/server/dist /app/server/dist
|
||||||
|
COPY --from=build /src/server/node_modules /app/server/node_modules
|
||||||
|
COPY --from=build /src/server/package.json /app/server/package.json
|
||||||
|
COPY --from=build /src/server/public /app/server/public
|
||||||
|
COPY --from=build /src/server/audio-editor /app/server/audio-editor
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY entrypoint.sh /usr/local/bin/ace-step-ui-entrypoint
|
||||||
|
RUN chmod 0755 /usr/local/bin/ace-step-ui-entrypoint \
|
||||||
|
&& mkdir -p /data/audio /data/datasets/uploads
|
||||||
|
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
ENTRYPOINT ["/usr/local/bin/ace-step-ui-entrypoint"]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
node /app/server/dist/index.js &
|
||||||
|
server_pid=$!
|
||||||
|
|
||||||
|
trap 'kill "$server_pid" 2>/dev/null || true' INT TERM EXIT
|
||||||
|
nginx -g 'daemon off;' &
|
||||||
|
nginx_pid=$!
|
||||||
|
|
||||||
|
while kill -0 "$server_pid" 2>/dev/null && kill -0 "$nginx_pid" 2>/dev/null; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
kill "$server_pid" "$nginx_pid" 2>/dev/null || true
|
||||||
|
wait "$server_pid" 2>/dev/null || true
|
||||||
|
wait "$nginx_pid" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
pid /tmp/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
client_max_body_size 512m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location ~ ^/(api|audio|editor|blog|demucs-web)(/|$) {
|
||||||
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 1800s;
|
||||||
|
proxy_send_timeout 1800s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const root = process.argv[2];
|
||||||
|
if (!root) throw new Error('source root argument is required');
|
||||||
|
|
||||||
|
function patch(relativePath, transform) {
|
||||||
|
const filename = path.join(root, relativePath);
|
||||||
|
const before = fs.readFileSync(filename, 'utf8');
|
||||||
|
const after = transform(before);
|
||||||
|
if (after === before) throw new Error(`patch made no change: ${relativePath}`);
|
||||||
|
fs.writeFileSync(filename, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOnce(text, before, after, label) {
|
||||||
|
const first = text.indexOf(before);
|
||||||
|
if (first < 0) throw new Error(`patch anchor missing: ${label}`);
|
||||||
|
if (text.indexOf(before, first + 1) >= 0) throw new Error(`patch anchor repeated: ${label}`);
|
||||||
|
return text.slice(0, first) + after + text.slice(first + before.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch('server/src/services/acestep.ts', (text) => {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
"const AUDIO_DIR = path.join(__dirname, '../../public/audio');",
|
||||||
|
'const AUDIO_DIR = config.storage.audioDir;',
|
||||||
|
'persistent generated audio',
|
||||||
|
);
|
||||||
|
|
||||||
|
text = text.replace("import { handle_file } from '@gradio/client';\n", '');
|
||||||
|
text = text.replace('getGradioClient, ', '');
|
||||||
|
|
||||||
|
const helperStart = text.indexOf('// Gradio generation: map params');
|
||||||
|
const helperEnd = text.indexOf('/**\n * Download a Gradio audio result file', helperStart);
|
||||||
|
if (helperStart < 0 || helperEnd < 0) throw new Error('legacy Gradio helper anchors missing');
|
||||||
|
const helperCommentStart = text.lastIndexOf('// ---------------------------------------------------------------------------', helperStart);
|
||||||
|
const namedHelpers = `// ---------------------------------------------------------------------------
|
||||||
|
// Named REST generation through ACE-Step's official /release_task endpoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveAudioPath(audioUrl: string): string {
|
||||||
|
if (audioUrl.startsWith('/audio/')) {
|
||||||
|
return path.join(AUDIO_DIR, audioUrl.replace('/audio/', ''));
|
||||||
|
}
|
||||||
|
if (audioUrl.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(audioUrl);
|
||||||
|
if (parsed.pathname.startsWith('/audio/')) {
|
||||||
|
return path.join(AUDIO_DIR, parsed.pathname.replace('/audio/', ''));
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return audioUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkerAudioPath(audioUrl: string | undefined): string | undefined {
|
||||||
|
if (!audioUrl) return undefined;
|
||||||
|
const localPath = resolveAudioPath(audioUrl);
|
||||||
|
if (!existsSync(localPath)) {
|
||||||
|
throw new Error(\`Uploaded audio is missing: \${localPath}\`);
|
||||||
|
}
|
||||||
|
const relativePath = path.relative(AUDIO_DIR, localPath);
|
||||||
|
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||||
|
throw new Error('Audio path is outside the shared Community UI storage');
|
||||||
|
}
|
||||||
|
return path.posix.join('/data/community-audio', relativePath.split(path.sep).join('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReleaseTaskPayload(params: GenerationParams): Record<string, unknown> {
|
||||||
|
const caption = params.style || 'pop music';
|
||||||
|
const prompt = params.customMode ? caption : (params.songDescription || caption);
|
||||||
|
const thinking = params.thinking ?? true;
|
||||||
|
const enhance = params.enhance ?? false;
|
||||||
|
const taskType = params.taskType === 'audio2audio' ? 'cover' : (params.taskType || 'text2music');
|
||||||
|
// A reference in text-to-music mode is only a global style/timbre guide.
|
||||||
|
// ACE-Step's own API guide recommends a low value (~0.2) for style transfer.
|
||||||
|
// Cover/source-audio jobs retain the explicitly selected cover strength.
|
||||||
|
const isStyleReference = taskType === 'text2music' && Boolean(params.referenceAudioUrl) && !params.sourceAudioUrl;
|
||||||
|
const effectiveAudioStrength = isStyleReference ? 0.2 : (params.audioCoverStrength ?? 1.0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt,
|
||||||
|
lyrics: params.instrumental ? '[Instrumental]' : (params.lyrics || ''),
|
||||||
|
instrumental: params.instrumental,
|
||||||
|
vocal_language: params.vocalLanguage || 'en',
|
||||||
|
bpm: params.bpm && params.bpm > 0 ? params.bpm : 0,
|
||||||
|
key_scale: params.keyScale || '',
|
||||||
|
time_signature: params.timeSignature || '',
|
||||||
|
audio_duration: params.duration && params.duration > 0 ? params.duration : -1,
|
||||||
|
inference_steps: params.inferenceSteps ?? 80,
|
||||||
|
guidance_scale: params.guidanceScale ?? 8.0,
|
||||||
|
shift: params.shift ?? 3.0,
|
||||||
|
infer_method: params.inferMethod || 'ode',
|
||||||
|
batch_size: Math.min(Math.max(params.batchSize ?? 1, 1), 16),
|
||||||
|
use_random_seed: params.randomSeed !== false,
|
||||||
|
seed: params.seed ?? -1,
|
||||||
|
thinking,
|
||||||
|
use_format: enhance,
|
||||||
|
lm_temperature: params.lmTemperature ?? 0.85,
|
||||||
|
lm_cfg_scale: params.lmCfgScale ?? 2.0,
|
||||||
|
lm_top_k: params.lmTopK ?? 0,
|
||||||
|
lm_top_p: params.lmTopP ?? 0.9,
|
||||||
|
lm_negative_prompt: params.lmNegativePrompt || 'NO USER INPUT',
|
||||||
|
use_cot_metas: thinking ? (params.useCotMetas ?? true) : false,
|
||||||
|
use_cot_caption: thinking ? (params.useCotCaption ?? true) : false,
|
||||||
|
use_cot_language: thinking ? (params.useCotLanguage ?? true) : false,
|
||||||
|
allow_lm_batch: params.allowLmBatch ?? true,
|
||||||
|
constrained_decoding_debug: params.constrainedDecodingDebug ?? false,
|
||||||
|
lm_batch_chunk_size: params.lmBatchChunkSize ?? 8,
|
||||||
|
task_type: taskType,
|
||||||
|
instruction: params.instruction || 'Fill the audio semantic mask based on the given conditions:',
|
||||||
|
reference_audio_path: resolveWorkerAudioPath(params.referenceAudioUrl),
|
||||||
|
src_audio_path: resolveWorkerAudioPath(params.sourceAudioUrl),
|
||||||
|
audio_codes: params.audioCodes || '',
|
||||||
|
repainting_start: params.repaintingStart ?? 0.0,
|
||||||
|
repainting_end: params.repaintingEnd ?? -1,
|
||||||
|
audio_cover_strength: effectiveAudioStrength,
|
||||||
|
use_adg: params.useAdg ?? false,
|
||||||
|
cfg_interval_start: params.cfgIntervalStart ?? 0.0,
|
||||||
|
cfg_interval_end: params.cfgIntervalEnd ?? 1.0,
|
||||||
|
audio_format: params.audioFormat || 'flac',
|
||||||
|
mp3_bitrate: '320k',
|
||||||
|
mp3_sample_rate: 48000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
`;
|
||||||
|
text = text.slice(0, helperCommentStart) + namedHelpers + text.slice(helperEnd);
|
||||||
|
|
||||||
|
const processStart = text.indexOf('// processGeneration — Gradio primary');
|
||||||
|
const processEnd = text.indexOf('function isAudioFile', processStart);
|
||||||
|
if (processStart < 0 || processEnd < 0) throw new Error('legacy generation anchors missing');
|
||||||
|
const processCommentStart = text.lastIndexOf('// ---------------------------------------------------------------------------', processStart);
|
||||||
|
const namedProcess = `// ---------------------------------------------------------------------------
|
||||||
|
// processGeneration — official named REST API only
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function processGeneration(
|
||||||
|
jobId: string,
|
||||||
|
params: GenerationParams,
|
||||||
|
job: ActiveJob,
|
||||||
|
): Promise<void> {
|
||||||
|
job.status = 'running';
|
||||||
|
job.stage = 'Preparing named ACE-Step request...';
|
||||||
|
|
||||||
|
if ((params.taskType === 'cover' || params.taskType === 'audio2audio') && !params.sourceAudioUrl && !params.audioCodes) {
|
||||||
|
job.status = 'failed';
|
||||||
|
job.error = \`task_type='\${params.taskType}' requires source audio or audio codes\`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (params.ditModel) {
|
||||||
|
job.stage = \`Loading model \${params.ditModel}...\`;
|
||||||
|
await switchModelIfNeeded(params.ditModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildReleaseTaskPayload(params);
|
||||||
|
console.log(\`Job \${jobId}: POST /release_task with named parameters\`, payload);
|
||||||
|
job.stage = 'Generating music via named ACE-Step API...';
|
||||||
|
|
||||||
|
const releaseResponse = await fetch(\`\${ACESTEP_API}/release_task\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const releaseText = await releaseResponse.text();
|
||||||
|
if (!releaseResponse.ok) {
|
||||||
|
throw new Error(\`ACE-Step /release_task failed (\${releaseResponse.status}): \${releaseText}\`);
|
||||||
|
}
|
||||||
|
const release = JSON.parse(releaseText) as any;
|
||||||
|
if (release.code !== 200 || !release.data?.task_id) {
|
||||||
|
throw new Error(release.error || 'ACE-Step returned no task_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = String(release.data.task_id);
|
||||||
|
job.taskId = taskId;
|
||||||
|
const queryResponse = await fetch(\`\${ACESTEP_API}/query_result\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ task_id_list: [taskId] }),
|
||||||
|
});
|
||||||
|
const queryText = await queryResponse.text();
|
||||||
|
if (!queryResponse.ok) {
|
||||||
|
throw new Error(\`ACE-Step /query_result failed (\${queryResponse.status}): \${queryText}\`);
|
||||||
|
}
|
||||||
|
const query = JSON.parse(queryText) as any;
|
||||||
|
const taskResult = query.data?.[0];
|
||||||
|
const audioItems = taskResult?.result ? JSON.parse(taskResult.result) : [];
|
||||||
|
if (!Array.isArray(audioItems) || audioItems.length === 0) {
|
||||||
|
throw new Error('ACE-Step completed without downloadable audio results');
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioUrls: string[] = [];
|
||||||
|
let actualDuration = 0;
|
||||||
|
for (const item of audioItems) {
|
||||||
|
if (!item?.url) continue;
|
||||||
|
const remoteUrl = new URL(item.url, ACESTEP_API).toString();
|
||||||
|
const remoteName = String(item.file || item.url);
|
||||||
|
const ext = path.extname(remoteName) || \`.\${params.audioFormat || 'flac'}\`;
|
||||||
|
const filename = \`\${jobId}_\${audioUrls.length}\${ext}\`;
|
||||||
|
const destPath = path.join(AUDIO_DIR, filename);
|
||||||
|
await downloadGradioAudioFile({ url: remoteUrl, orig_name: remoteName }, destPath);
|
||||||
|
if (audioUrls.length === 0) actualDuration = getAudioDuration(destPath);
|
||||||
|
audioUrls.push(\`/audio/\${filename}\`);
|
||||||
|
}
|
||||||
|
if (audioUrls.length === 0) throw new Error('ACE-Step returned no supported audio files');
|
||||||
|
|
||||||
|
const first = audioItems[0] || {};
|
||||||
|
job.status = 'succeeded';
|
||||||
|
job.result = {
|
||||||
|
audioUrls,
|
||||||
|
duration: actualDuration || Number(first.duration) || params.duration || 0,
|
||||||
|
bpm: Number(first.bpm) || params.bpm,
|
||||||
|
keyScale: first.keyscale || params.keyScale,
|
||||||
|
timeSignature: first.timesignature || params.timeSignature,
|
||||||
|
status: 'succeeded',
|
||||||
|
};
|
||||||
|
job.rawResponse = { release, query, transmittedParameters: payload };
|
||||||
|
console.log(\`Job \${jobId}: Completed via named REST API with \${audioUrls.length} audio files\`);
|
||||||
|
} catch (error) {
|
||||||
|
job.status = 'failed';
|
||||||
|
job.error = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(\`Job \${jobId}: Named REST generation failed\`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
`;
|
||||||
|
text = text.slice(0, processCommentStart) + namedProcess + text.slice(processEnd);
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
patch('server/src/services/storage/local.ts', (text) => {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
"import type { StorageProvider } from './index.js';",
|
||||||
|
"import type { StorageProvider } from './index.js';\nimport { config } from '../../config/index.js';",
|
||||||
|
'storage config import',
|
||||||
|
);
|
||||||
|
return replaceOnce(
|
||||||
|
text,
|
||||||
|
"const AUDIO_DIR = path.join(__dirname, '../../../public/audio');",
|
||||||
|
'const AUDIO_DIR = config.storage.audioDir;',
|
||||||
|
'persistent uploaded audio',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
patch('server/src/index.ts', (text) => replaceOnce(
|
||||||
|
text,
|
||||||
|
"app.use('/audio', express.static(path.join(__dirname, '../public/audio')));",
|
||||||
|
"app.use('/audio', express.static(config.storage.audioDir));",
|
||||||
|
'persistent audio static route',
|
||||||
|
));
|
||||||
|
|
||||||
|
patch('server/src/routes/referenceTrack.ts', (text) => {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
"import { spawn } from 'child_process';",
|
||||||
|
"import { spawn } from 'child_process';\nimport { config } from '../config/index.js';",
|
||||||
|
'reference audio config import',
|
||||||
|
);
|
||||||
|
return replaceOnce(
|
||||||
|
text,
|
||||||
|
"const AUDIO_DIR = path.join(__dirname, '../../public/audio');",
|
||||||
|
'const AUDIO_DIR = config.storage.audioDir;',
|
||||||
|
'persistent reference audio',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
patch('server/src/routes/generate.ts', (text) => {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
" thinking?: boolean;\n audioFormat?: 'mp3' | 'flac';",
|
||||||
|
" thinking?: boolean;\n enhance?: boolean;\n audioFormat?: 'mp3' | 'flac';",
|
||||||
|
'server enhance type',
|
||||||
|
);
|
||||||
|
const enhanceAnchor = ' thinking,\n audioFormat,';
|
||||||
|
if (text.split(enhanceAnchor).length - 1 !== 2) {
|
||||||
|
throw new Error('expected enhance anchor in destructuring and forwarding');
|
||||||
|
}
|
||||||
|
text = text.replaceAll(enhanceAnchor, ' thinking,\n enhance,\n audioFormat,');
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
" const ALL_DIT_MODELS = [\n 'acestep-v15-turbo',",
|
||||||
|
" const ALL_DIT_MODELS = [\n 'acestep-v15-xl-sft', // Athena production model\n 'acestep-v15-turbo',",
|
||||||
|
'XL-SFT model list',
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = text.indexOf("router.get('/limits'");
|
||||||
|
const end = text.indexOf("router.get('/debug/", start);
|
||||||
|
if (start < 0 || end < 0) throw new Error('limits route anchors missing');
|
||||||
|
const limits = `router.get('/limits', async (_req, res: Response) => {
|
||||||
|
// The UI container intentionally has no CUDA or ACE-Step Python runtime.
|
||||||
|
// These are the limits reported by Athena's dedicated RTX 5080 worker.
|
||||||
|
res.json({
|
||||||
|
tier: process.env.ACESTEP_TIER || 'tier5',
|
||||||
|
gpu_memory_gb: Number(process.env.ACESTEP_GPU_MEMORY_GB || 15.5),
|
||||||
|
max_duration_with_lm: Number(process.env.ACESTEP_MAX_DURATION_WITH_LM || 480),
|
||||||
|
max_duration_without_lm: Number(process.env.ACESTEP_MAX_DURATION_WITHOUT_LM || 600),
|
||||||
|
max_batch_size_with_lm: Number(process.env.ACESTEP_MAX_BATCH_WITH_LM || 4),
|
||||||
|
max_batch_size_without_lm: Number(process.env.ACESTEP_MAX_BATCH_WITHOUT_LM || 4),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
`;
|
||||||
|
return text.slice(0, start) + limits + text.slice(end);
|
||||||
|
});
|
||||||
|
|
||||||
|
patch('services/api.ts', (text) => replaceOnce(
|
||||||
|
text,
|
||||||
|
" thinking?: boolean;\n audioFormat?: 'mp3' | 'flac';",
|
||||||
|
" thinking?: boolean;\n enhance?: boolean;\n audioFormat?: 'mp3' | 'flac';",
|
||||||
|
'client enhance type',
|
||||||
|
));
|
||||||
|
|
||||||
|
patch('App.tsx', (text) => {
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' thinking: params.thinking,\n audioFormat: params.audioFormat,',
|
||||||
|
' thinking: params.thinking,\n enhance: params.enhance,\n audioFormat: params.audioFormat,',
|
||||||
|
'client enhance forwarding',
|
||||||
|
);
|
||||||
|
return replaceOnce(
|
||||||
|
text,
|
||||||
|
' title: params.title,\n instrumental: params.instrumental,',
|
||||||
|
' title: params.title,\n ditModel: params.ditModel,\n instrumental: params.instrumental,',
|
||||||
|
'client model forwarding',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
patch('components/CreatePanel.tsx', (text) => {
|
||||||
|
text = replaceOnce(text, 'useState(9.0);', 'useState(8.0);', 'guidance default');
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
'useState(false); // Default false for GPU compatibility',
|
||||||
|
'useState(true); // Athena default: use the 1.7B planner for coherent structure',
|
||||||
|
'thinking default',
|
||||||
|
);
|
||||||
|
text = replaceOnce(text, "useState<'mp3' | 'flac'>('mp3');", "useState<'mp3' | 'flac'>('flac');", 'lossless default');
|
||||||
|
text = replaceOnce(text, 'useState(12);', 'useState(80);', 'XL-SFT steps default');
|
||||||
|
text = replaceOnce(text, "localStorage.getItem('ace-lmModel') || 'acestep-5Hz-lm-0.6B'", "localStorage.getItem('ace-lmModel') || 'acestep-5Hz-lm-1.7B'", 'planner model default');
|
||||||
|
// Upstream already defaults to Shift 3. Keep it instead of replacing it.
|
||||||
|
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' // Bulk generation: loop bulkCount times\n for (let i = 0; i < bulkCount; i++) {',
|
||||||
|
` const requestedText = customMode ? styleWithGender : songDescription;
|
||||||
|
const vocalRequestText = \`\${requestedText || ''}\\n\${lyrics}\`;
|
||||||
|
const explicitlyNoVocals = /\\b(no vocals?|without vocals?|instrumental only|kein(?:e[rs]?)? gesang|ohne gesang|keine stimme|ohne stimme)\\b/i.test(vocalRequestText);
|
||||||
|
const asksForVocals = !explicitlyNoVocals && /\\b(vocals?|singer|singing|male voice|female voice|gesang|stimme|sänger(?:in)?|singt)\\b/i.test(vocalRequestText);
|
||||||
|
if (!instrumental && asksForVocals && !lyrics.trim()) {
|
||||||
|
window.alert('Widerspruch: Der Auftrag verlangt Gesang, aber das Liedtextfeld ist leer. Bitte Text eintragen oder „Instrumental“ wählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (instrumental && asksForVocals) {
|
||||||
|
window.alert('Widerspruch: „Instrumental“ ist aktiv, aber die Beschreibung verlangt Gesang. Bitte Gesangsbegriffe aus der Beschreibung entfernen oder „Instrumental“ deaktivieren und einen Liedtext eintragen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((taskType === 'cover' || taskType === 'audio2audio') && !sourceAudioUrl.trim() && !audioCodes.trim()) {
|
||||||
|
window.alert('Für einen Cover-Auftrag fehlt das Quellaudio. Bitte unter „Quellaudio / Cover“ eine Datei auswählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskLabel = taskType === 'cover' || taskType === 'audio2audio' ? 'Cover / Audio-zu-Audio' : taskType;
|
||||||
|
const summary = [
|
||||||
|
'Folgende Parameter werden tatsächlich an Athena übertragen:',
|
||||||
|
'',
|
||||||
|
\`Aufgabe: \${taskLabel}\`,
|
||||||
|
\`Modell: \${selectedModel}\`,
|
||||||
|
\`Dauer: \${duration > 0 ? \`\${duration} Sekunden\` : 'automatisch'}\`,
|
||||||
|
\`Tempo: \${bpm > 0 ? \`\${bpm} BPM\` : 'automatisch'}\`,
|
||||||
|
\`Tonart: \${keyScale || 'automatisch'}\`,
|
||||||
|
\`Taktart: \${timeSignature || 'automatisch'}\`,
|
||||||
|
\`Thinking/Planung: \${thinking ? 'AN' : 'AUS'}\`,
|
||||||
|
\`AI Enhance: \${enhance ? 'AN' : 'AUS'}\`,
|
||||||
|
\`XL-SFT: \${inferenceSteps} Schritte, Guidance \${guidanceScale}, Shift \${shift}\`,
|
||||||
|
\`Gesang: \${instrumental ? 'nein (Instrumental)' : 'ja'}\`,
|
||||||
|
\`Referenzaudio (nur Klang/Produktion): \${referenceAudioUrl ? 'vorhanden' : 'keines'}\`,
|
||||||
|
\`Quellaudio (Melodie/Rhythmus/Akkorde): \${sourceAudioUrl ? 'vorhanden' : 'keines'}\`,
|
||||||
|
\`Audio-Einfluss: \${referenceAudioUrl && !sourceAudioUrl && taskType === 'text2music' ? '0,2 (sichere Stilreferenz)' : audioCoverStrength}\`,
|
||||||
|
\`Ausgabe: \${audioFormat.toUpperCase()}, \${batchSize} Variation(en), \${bulkCount} Auftrag/Aufträge\`,
|
||||||
|
'',
|
||||||
|
'Auftrag jetzt starten?',
|
||||||
|
].join('\\n');
|
||||||
|
if (!window.confirm(summary)) return;
|
||||||
|
|
||||||
|
// Bulk generation: loop bulkCount times
|
||||||
|
for (let i = 0; i < bulkCount; i++) {`,
|
||||||
|
'validation and transmitted parameter summary',
|
||||||
|
);
|
||||||
|
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
" {t('reference')}\n </button>",
|
||||||
|
" Referenzaudio\n </button>",
|
||||||
|
'reference tab label',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
" {t('cover')}\n </button>",
|
||||||
|
" Quellaudio / Cover\n </button>",
|
||||||
|
'source tab label',
|
||||||
|
);
|
||||||
|
text = replaceOnce(
|
||||||
|
text,
|
||||||
|
' {/* Audio Content */}\n <div className="p-3 space-y-2">',
|
||||||
|
` {/* Audio Content */}
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<p className="text-[11px] leading-relaxed text-zinc-500 dark:text-zinc-400">
|
||||||
|
{audioTab === 'reference'
|
||||||
|
? 'Referenzaudio beeinflusst nur Klang, Instrumentierung und Produktion – nicht die Melodie.'
|
||||||
|
: 'Quellaudio / Cover erhält Melodie, Rhythmus und Akkorde des hochgeladenen Titels.'}
|
||||||
|
</p>`,
|
||||||
|
'audio semantics explanation',
|
||||||
|
);
|
||||||
|
return text;
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
services:
|
||||||
|
music-worker:
|
||||||
|
build:
|
||||||
|
context: ./worker
|
||||||
|
image: mike-ai/ace-step-1.5:named-api-v1
|
||||||
|
container_name: mike-ai-music-acestep-test
|
||||||
|
labels:
|
||||||
|
com.mike-ai.music-worker: "acestep"
|
||||||
|
profiles: ["music-test"]
|
||||||
|
environment:
|
||||||
|
ACESTEP_MODE: gradio
|
||||||
|
ACESTEP_CONFIG_PATH: acestep-v15-xl-sft
|
||||||
|
ACESTEP_LM_MODEL_PATH: acestep-5Hz-lm-1.7B
|
||||||
|
ACESTEP_LLM_BACKEND: pt
|
||||||
|
ACESTEP_INIT_SERVICE: "true"
|
||||||
|
ACESTEP_INIT_LLM: "true"
|
||||||
|
ACESTEP_DEVICE: cuda
|
||||||
|
# The image entrypoint forwards only ACESTEP_EXTRA_ARGS to the UI CLI.
|
||||||
|
# One result per run avoids the batch=2 VRAM/time penalty.
|
||||||
|
# Named Gradio endpoints are consumed by the separate ace-step-ui service.
|
||||||
|
ACESTEP_EXTRA_ARGS: "--batch_size 1 --enable-api"
|
||||||
|
TOKENIZERS_PARALLELISM: "false"
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${ACESTEP_GPU_UUID:?set ACESTEP_GPU_UUID to the RTX 5080 UUID}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["${ACESTEP_GPU_UUID:?set ACESTEP_GPU_UUID to the RTX 5080 UUID}"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
ports:
|
||||||
|
# Raw Gradio remains available for diagnostics; users open music-ui below.
|
||||||
|
- "127.0.0.1:${ACESTEP_GRADIO_PORT:-7862}:7860"
|
||||||
|
volumes:
|
||||||
|
- ${ACESTEP_CHECKPOINTS_DIR:-/data/models/acestep/checkpoints}:/app/checkpoints
|
||||||
|
- ${ACESTEP_HF_CACHE_DIR:-/data/models/acestep/hf-cache}:/root/.cache/huggingface
|
||||||
|
- ${ACESTEP_OUTPUT_DIR:-/data/music/acestep}:/app/gradio_outputs
|
||||||
|
- ${ACESTEP_OUTPUT_DIR:-/data/music/acestep}:/app/output
|
||||||
|
# Uploaded Community-UI audio is shared read-only with the named REST API.
|
||||||
|
- ${ACESTEP_UI_DATA_DIR:-/data/music/ace-step-ui}/audio:/data/community-audio:ro
|
||||||
|
# Version-pinned UI defaults for XL-SFT quality and lossless output.
|
||||||
|
- ./overrides/model_config.py:/app/acestep/ui/gradio/events/generation/model_config.py:ro
|
||||||
|
- ./overrides/generation_advanced_output_controls.py:/app/acestep/ui/gradio/interfaces/generation_advanced_output_controls.py:ro
|
||||||
|
- ./overrides/user_preferences.py:/app/acestep/ui/gradio/interfaces/user_preferences.py:ro
|
||||||
|
- ./overrides/user_preferences.js:/app/acestep/ui/gradio/interfaces/user_preferences.js:ro
|
||||||
|
shm_size: "2gb"
|
||||||
|
restart: "no"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:7860/ >/dev/null"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 300s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- music
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
music-ui:
|
||||||
|
build:
|
||||||
|
context: ./ace-step-ui
|
||||||
|
args:
|
||||||
|
ACE_STEP_UI_COMMIT: a1fdf91829ec6f7b98844f80e323529cd155dbf2
|
||||||
|
image: mike-ai/ace-step-ui:a1fdf918
|
||||||
|
container_name: mike-ai-music-ui
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "3001"
|
||||||
|
FRONTEND_URL: ${ACESTEP_UI_PUBLIC_URL:-http://192.168.1.212:7861}
|
||||||
|
ACESTEP_API_URL: http://music-worker:7860
|
||||||
|
DATABASE_PATH: /data/acestep.db
|
||||||
|
AUDIO_DIR: /data/audio
|
||||||
|
DATASETS_DIR: /data/datasets
|
||||||
|
DATASETS_UPLOADS_DIR: /data/datasets/uploads
|
||||||
|
JWT_SECRET: ${ACESTEP_UI_JWT_SECRET:?set ACESTEP_UI_JWT_SECRET}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${ACESTEP_UI_PORT:-7861}:3000"
|
||||||
|
volumes:
|
||||||
|
- ${ACESTEP_UI_DATA_DIR:-/data/music/ace-step-ui}:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/ >/dev/null && curl -fsS http://127.0.0.1:3001/health >/dev/null"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 30s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- music
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
networks:
|
||||||
|
music:
|
||||||
|
name: mike-ai-music
|
||||||
|
frontend:
|
||||||
|
external: true
|
||||||
|
name: mike-ai_frontend
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Output and automation controls for generation advanced settings."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import gradio as gr
|
||||||
|
|
||||||
|
from acestep.ui.gradio.i18n import t
|
||||||
|
|
||||||
|
|
||||||
|
_MP3_BITRATE_CHOICES = [("128 kbps", "128k"), ("192 kbps", "192k"), ("256 kbps", "256k"), ("320 kbps", "320k")]
|
||||||
|
_MP3_SAMPLE_RATE_CHOICES = [("48 kHz", 48000), ("44.1 kHz", 44100)]
|
||||||
|
|
||||||
|
|
||||||
|
def _update_mp3_control_visibility(audio_format: str, service_mode: bool = False):
|
||||||
|
"""Return visibility and interactivity updates for MP3-only controls."""
|
||||||
|
visible = audio_format == "mp3"
|
||||||
|
interactive = visible and not service_mode
|
||||||
|
return (
|
||||||
|
gr.update(visible=visible),
|
||||||
|
gr.update(choices=_MP3_BITRATE_CHOICES, visible=visible, interactive=interactive),
|
||||||
|
gr.update(choices=_MP3_SAMPLE_RATE_CHOICES, visible=visible, interactive=interactive),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_output_controls(
|
||||||
|
service_pre_initialized: bool,
|
||||||
|
service_mode: bool,
|
||||||
|
init_params: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create audio-output and post-processing controls for advanced settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_pre_initialized: Whether existing init params should prefill values.
|
||||||
|
service_mode: Whether the UI is running in service mode (disables some controls).
|
||||||
|
init_params: Optional startup state containing persisted output values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A component map containing format, scoring, normalization, and latent controls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = init_params or {}
|
||||||
|
# Keep the master lossless. MP3 is only an optional sharing export.
|
||||||
|
initial_audio_format = params.get("audio_format", "flac")
|
||||||
|
initial_mp3_visible = initial_audio_format == "mp3"
|
||||||
|
with gr.Accordion(t("generation.advanced_output_section"), open=False, elem_classes=["has-info-container"]):
|
||||||
|
with gr.Row():
|
||||||
|
with gr.Column(scale=1):
|
||||||
|
audio_format = gr.Dropdown(
|
||||||
|
choices=[
|
||||||
|
("FLAC", "flac"),
|
||||||
|
("MP3", "mp3"),
|
||||||
|
("Opus", "opus"),
|
||||||
|
("AAC", "aac"),
|
||||||
|
("WAV (16-bit)", "wav"),
|
||||||
|
("WAV (32-bit Float)", "wav32"),
|
||||||
|
],
|
||||||
|
value=initial_audio_format,
|
||||||
|
label=t("generation.audio_format_label"),
|
||||||
|
info=t("generation.audio_format_info"),
|
||||||
|
elem_id="acestep-audio-format",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
interactive=not service_mode,
|
||||||
|
)
|
||||||
|
with gr.Row(visible=initial_mp3_visible) as mp3_controls_row:
|
||||||
|
mp3_bitrate = gr.Dropdown(
|
||||||
|
choices=[
|
||||||
|
("128 kbps", "128k"),
|
||||||
|
("192 kbps", "192k"),
|
||||||
|
("256 kbps", "256k"),
|
||||||
|
("320 kbps", "320k"),
|
||||||
|
],
|
||||||
|
value=params.get("mp3_bitrate", "320k"),
|
||||||
|
label=t("generation.mp3_bitrate_label"),
|
||||||
|
info=t("generation.mp3_bitrate_info"),
|
||||||
|
elem_id="acestep-mp3-bitrate",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
visible=initial_mp3_visible,
|
||||||
|
interactive=initial_mp3_visible and not service_mode,
|
||||||
|
scale=1,
|
||||||
|
)
|
||||||
|
mp3_sample_rate = gr.Dropdown(
|
||||||
|
choices=[
|
||||||
|
("48 kHz", 48000),
|
||||||
|
("44.1 kHz", 44100),
|
||||||
|
],
|
||||||
|
value=params.get("mp3_sample_rate", 48000),
|
||||||
|
label=t("generation.mp3_sample_rate_label"),
|
||||||
|
info=t("generation.mp3_sample_rate_info"),
|
||||||
|
elem_id="acestep-mp3-sample-rate",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
visible=initial_mp3_visible,
|
||||||
|
interactive=initial_mp3_visible and not service_mode,
|
||||||
|
scale=1,
|
||||||
|
)
|
||||||
|
with gr.Column(scale=1):
|
||||||
|
score_scale = gr.Slider(
|
||||||
|
minimum=0.01,
|
||||||
|
maximum=1.0,
|
||||||
|
value=0.5,
|
||||||
|
step=0.01,
|
||||||
|
label=t("generation.score_sensitivity_label"),
|
||||||
|
info=t("generation.score_sensitivity_info"),
|
||||||
|
elem_id="acestep-score-scale",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
scale=1,
|
||||||
|
visible=not service_mode,
|
||||||
|
)
|
||||||
|
audio_format.change(
|
||||||
|
fn=lambda value: _update_mp3_control_visibility(value, service_mode),
|
||||||
|
inputs=[audio_format],
|
||||||
|
outputs=[mp3_controls_row, mp3_bitrate, mp3_sample_rate],
|
||||||
|
)
|
||||||
|
with gr.Row():
|
||||||
|
enable_normalization = gr.Checkbox(
|
||||||
|
label=t("generation.enable_normalization"),
|
||||||
|
value=params.get("enable_normalization", True) if service_pre_initialized else True,
|
||||||
|
info=t("generation.enable_normalization_info"),
|
||||||
|
elem_id="acestep-enable-normalization",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
)
|
||||||
|
normalization_db = gr.Slider(
|
||||||
|
label=t("generation.normalization_db"),
|
||||||
|
minimum=-10.0,
|
||||||
|
maximum=0.0,
|
||||||
|
step=0.1,
|
||||||
|
value=params.get("normalization_db", -1.0) if service_pre_initialized else -1.0,
|
||||||
|
info=t("generation.normalization_db_info"),
|
||||||
|
elem_id="acestep-normalization-db",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
)
|
||||||
|
with gr.Row():
|
||||||
|
fade_in_duration = gr.Slider(
|
||||||
|
label=t("generation.fade_in_duration"),
|
||||||
|
minimum=0.0,
|
||||||
|
maximum=10.0,
|
||||||
|
step=0.1,
|
||||||
|
value=params.get("fade_in_duration", 0.0) if service_pre_initialized else 0.0,
|
||||||
|
info=t("generation.fade_in_duration_info"),
|
||||||
|
elem_id="acestep-fade-in-duration",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
)
|
||||||
|
fade_out_duration = gr.Slider(
|
||||||
|
label=t("generation.fade_out_duration"),
|
||||||
|
minimum=0.0,
|
||||||
|
maximum=10.0,
|
||||||
|
step=0.1,
|
||||||
|
value=params.get("fade_out_duration", 0.0) if service_pre_initialized else 0.0,
|
||||||
|
info=t("generation.fade_out_duration_info"),
|
||||||
|
elem_id="acestep-fade-out-duration",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
)
|
||||||
|
with gr.Row():
|
||||||
|
latent_shift = gr.Slider(
|
||||||
|
label=t("generation.latent_shift"),
|
||||||
|
minimum=-0.2,
|
||||||
|
maximum=0.2,
|
||||||
|
step=0.01,
|
||||||
|
value=params.get("latent_shift", 0.0) if service_pre_initialized else 0.0,
|
||||||
|
info=t("generation.latent_shift_info"),
|
||||||
|
elem_id="acestep-latent-shift",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
)
|
||||||
|
latent_rescale = gr.Slider(
|
||||||
|
label=t("generation.latent_rescale"),
|
||||||
|
minimum=0.5,
|
||||||
|
maximum=1.5,
|
||||||
|
step=0.01,
|
||||||
|
value=params.get("latent_rescale", 1.0) if service_pre_initialized else 1.0,
|
||||||
|
info=t("generation.latent_rescale_info"),
|
||||||
|
elem_id="acestep-latent-rescale",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"audio_format": audio_format,
|
||||||
|
"mp3_controls_row": mp3_controls_row,
|
||||||
|
"mp3_bitrate": mp3_bitrate,
|
||||||
|
"mp3_sample_rate": mp3_sample_rate,
|
||||||
|
"score_scale": score_scale,
|
||||||
|
"enable_normalization": enable_normalization,
|
||||||
|
"normalization_db": normalization_db,
|
||||||
|
"fade_in_duration": fade_in_duration,
|
||||||
|
"fade_out_duration": fade_out_duration,
|
||||||
|
"latent_shift": latent_shift,
|
||||||
|
"latent_rescale": latent_rescale,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_automation_controls(service_mode: bool) -> dict[str, Any]:
|
||||||
|
"""Create automation controls for LM batch chunking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_mode: Whether the UI is running in service mode (disables some controls).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A component map containing ``lm_batch_chunk_size``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with gr.Accordion(
|
||||||
|
t("generation.advanced_automation_section"),
|
||||||
|
open=False,
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
):
|
||||||
|
with gr.Row():
|
||||||
|
lm_batch_chunk_size = gr.Number(
|
||||||
|
label=t("generation.lm_batch_chunk_label"),
|
||||||
|
value=8,
|
||||||
|
minimum=1,
|
||||||
|
maximum=32,
|
||||||
|
step=1,
|
||||||
|
info=t("generation.lm_batch_chunk_info"),
|
||||||
|
scale=1,
|
||||||
|
interactive=not service_mode,
|
||||||
|
elem_id="acestep-lm-batch-chunk-size",
|
||||||
|
elem_classes=["has-info-container"],
|
||||||
|
)
|
||||||
|
return {"lm_batch_chunk_size": lm_batch_chunk_size}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Model configuration and UI control settings for generation handlers.
|
||||||
|
|
||||||
|
Contains functions for determining model type (turbo/base/pure-base),
|
||||||
|
producing UI control configurations, and computing gr.update() tuples
|
||||||
|
for model-type-dependent controls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import gradio as gr
|
||||||
|
|
||||||
|
from acestep.constants import (
|
||||||
|
TASK_TYPES_TURBO,
|
||||||
|
TASK_TYPES_BASE,
|
||||||
|
GENERATION_MODES_TURBO,
|
||||||
|
GENERATION_MODES_BASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_token(token: str, path: str) -> bool:
|
||||||
|
"""Check if *token* appears as a delimited word in *path*.
|
||||||
|
|
||||||
|
Matches when *token* is bounded by start/end of string or a common
|
||||||
|
path delimiter (``/``, ``\\``, ``.``, ``_``, ``-``).
|
||||||
|
"""
|
||||||
|
return re.search(rf"(^|[\\\\/._-]){token}($|[\\\\/._-])", path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_pure_base_model(config_path_lower: str) -> bool:
|
||||||
|
"""Check whether a model path refers to a pure base model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path_lower: Lowercased model config path string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` when the path contains ``"base"`` and excludes ``"sft"`` and ``"turbo"``.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
_has_token("base", config_path_lower)
|
||||||
|
and not _has_token("sft", config_path_lower)
|
||||||
|
and not _has_token("turbo", config_path_lower)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_model_type_settings(config_path: str | None, current_mode: str | None = None) -> tuple:
|
||||||
|
"""Update UI settings based on model type (fallback when handler not initialized yet).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Model config path string.
|
||||||
|
current_mode: Current generation mode value to preserve across choices update.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ten-element tuple of ``gr.update()`` dicts for inference_steps,
|
||||||
|
guidance_scale, use_adg, shift, cfg_interval_start, cfg_interval_end,
|
||||||
|
task_type, generation_mode, init_llm_checkbox, and dcw_enabled.
|
||||||
|
"""
|
||||||
|
if config_path is None:
|
||||||
|
config_path = ""
|
||||||
|
config_path_lower = config_path.lower()
|
||||||
|
|
||||||
|
# Precedence: turbo > SFT > pure base > fallback.
|
||||||
|
# Detection functions enforce mutual exclusivity.
|
||||||
|
is_turbo = _has_token("turbo", config_path_lower)
|
||||||
|
is_pure_base = is_pure_base_model(config_path_lower)
|
||||||
|
is_sft = is_sft_model(config_path_lower)
|
||||||
|
|
||||||
|
return get_model_type_ui_settings(is_turbo, current_mode=current_mode, is_pure_base=is_pure_base, is_sft=is_sft)
|
||||||
|
|
||||||
|
|
||||||
|
def is_sft_model(config_path_lower: str) -> bool:
|
||||||
|
"""Check whether a model path refers to an SFT (supervised fine-tuned) model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path_lower: Lowercased model config path string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` when the path contains ``"sft"`` and excludes ``"turbo"``.
|
||||||
|
"""
|
||||||
|
return _has_token("sft", config_path_lower) and not _has_token("turbo", config_path_lower)
|
||||||
|
|
||||||
|
|
||||||
|
def is_xl_model(config_path_lower: str) -> bool:
|
||||||
|
"""Check whether a model path refers to an XL (4B DiT) variant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path_lower: Lowercased model config path string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` when the path contains ``"xl"`` as a delimited token.
|
||||||
|
"""
|
||||||
|
return _has_token("xl", config_path_lower)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ui_control_config(is_turbo: bool, is_pure_base: bool = False, is_sft: bool = False) -> dict:
|
||||||
|
"""Return UI control configuration (values, limits, visibility) for model type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_turbo: Whether the model is a turbo variant.
|
||||||
|
is_pure_base: Whether the model is a pure base model.
|
||||||
|
is_sft: Whether the model is an SFT (supervised fine-tuned) variant.
|
||||||
|
SFT models are optimized for 50 inference steps, matching the
|
||||||
|
training defaults in model_discovery._BASE_DEFAULTS.
|
||||||
|
|
||||||
|
Used by both interactive init and service-mode startup so controls stay consistent.
|
||||||
|
"""
|
||||||
|
# Precedence: turbo > SFT > pure base > fallback.
|
||||||
|
if is_pure_base:
|
||||||
|
task_choices = TASK_TYPES_BASE
|
||||||
|
mode_choices = GENERATION_MODES_BASE
|
||||||
|
else:
|
||||||
|
task_choices = TASK_TYPES_TURBO
|
||||||
|
mode_choices = GENERATION_MODES_TURBO
|
||||||
|
|
||||||
|
if is_turbo:
|
||||||
|
return {
|
||||||
|
"inference_steps_value": 8,
|
||||||
|
"inference_steps_maximum": 20,
|
||||||
|
"inference_steps_minimum": 1,
|
||||||
|
"guidance_scale_visible": False,
|
||||||
|
"use_adg_visible": False,
|
||||||
|
"shift_value": 3.0,
|
||||||
|
"shift_visible": True,
|
||||||
|
"dcw_enabled_value": True,
|
||||||
|
"cfg_interval_start_visible": False,
|
||||||
|
"cfg_interval_end_visible": False,
|
||||||
|
"task_type_choices": task_choices,
|
||||||
|
"generation_mode_choices": mode_choices,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# SFT models use 50 steps; pure base / unknown models use 32.
|
||||||
|
steps = 50 if is_sft else 32
|
||||||
|
return {
|
||||||
|
"inference_steps_value": steps,
|
||||||
|
"inference_steps_maximum": 200,
|
||||||
|
"inference_steps_minimum": 1,
|
||||||
|
"guidance_scale_visible": True,
|
||||||
|
"use_adg_visible": True,
|
||||||
|
# ACE-Step XL-SFT was trained/recommended with shift=1.0.
|
||||||
|
# Keep 3.0 only for non-SFT base/unknown variants.
|
||||||
|
"shift_value": 1.0 if is_sft else 3.0,
|
||||||
|
"shift_visible": True,
|
||||||
|
"dcw_enabled_value": False,
|
||||||
|
"cfg_interval_start_visible": True,
|
||||||
|
"cfg_interval_end_visible": True,
|
||||||
|
"task_type_choices": task_choices,
|
||||||
|
"generation_mode_choices": mode_choices,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_type_ui_settings(is_turbo: bool, current_mode: str | None = None, is_pure_base: bool = False, is_sft: bool = False):
|
||||||
|
"""Get gr.update() tuple for model-type controls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_turbo: Whether the model is a turbo variant.
|
||||||
|
current_mode: Current generation mode value to preserve.
|
||||||
|
is_pure_base: Whether the model is a pure base model.
|
||||||
|
is_sft: Whether the model is an SFT variant.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of updates for inference_steps, guidance_scale, use_adg,
|
||||||
|
shift, cfg_interval_start, cfg_interval_end, task_type,
|
||||||
|
generation_mode, init_llm_checkbox, and dcw_enabled.
|
||||||
|
"""
|
||||||
|
cfg = get_ui_control_config(is_turbo, is_pure_base=is_pure_base, is_sft=is_sft)
|
||||||
|
new_choices = cfg["generation_mode_choices"]
|
||||||
|
if current_mode and current_mode in new_choices:
|
||||||
|
mode_update = gr.update(choices=new_choices, value=current_mode)
|
||||||
|
else:
|
||||||
|
mode_update = gr.update(choices=new_choices)
|
||||||
|
init_llm_update = gr.update(value=False) if is_pure_base else gr.update()
|
||||||
|
return (
|
||||||
|
gr.update(
|
||||||
|
value=cfg["inference_steps_value"],
|
||||||
|
maximum=cfg["inference_steps_maximum"],
|
||||||
|
minimum=cfg["inference_steps_minimum"],
|
||||||
|
),
|
||||||
|
gr.update(visible=cfg["guidance_scale_visible"]),
|
||||||
|
gr.update(visible=cfg["use_adg_visible"]),
|
||||||
|
gr.update(value=cfg["shift_value"], visible=cfg["shift_visible"]),
|
||||||
|
gr.update(visible=cfg["cfg_interval_start_visible"]),
|
||||||
|
gr.update(visible=cfg["cfg_interval_end_visible"]),
|
||||||
|
gr.skip(), # task_type (gr.State — no-op on model config change)
|
||||||
|
mode_update,
|
||||||
|
init_llm_update,
|
||||||
|
gr.update(value=cfg["dcw_enabled_value"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_generation_mode_choices(is_pure_base: bool = False) -> list:
|
||||||
|
"""Get the list of generation mode choices based on model type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_pure_base: Whether the model is a pure base model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of mode choice strings.
|
||||||
|
"""
|
||||||
|
if is_pure_base:
|
||||||
|
return GENERATION_MODES_BASE
|
||||||
|
else:
|
||||||
|
return GENERATION_MODES_TURBO
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* User preferences persistence – SAVE side only.
|
||||||
|
*
|
||||||
|
* Listens for user changes on Gradio UI controls and persists the current
|
||||||
|
* values to browser localStorage. Restoration is handled on the Python side
|
||||||
|
* via ``gr.Blocks.load()`` so Gradio's own Svelte reactivity updates every
|
||||||
|
* component correctly.
|
||||||
|
*
|
||||||
|
* Storage schema:
|
||||||
|
* key = "acestep.ui.user_preferences"
|
||||||
|
* value = JSON { _version: 2, audio_format: "flac", … }
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const STORAGE_KEY = "acestep.ui.user_preferences";
|
||||||
|
const SCHEMA_VERSION = 2;
|
||||||
|
const DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of preference key → { elemId, type }.
|
||||||
|
* elemId : the HTML elem_id set in Gradio
|
||||||
|
* type : "dropdown" | "slider" | "checkbox" | "number"
|
||||||
|
*/
|
||||||
|
const PREFS = {
|
||||||
|
audio_format: { elemId: "acestep-audio-format", type: "dropdown" },
|
||||||
|
mp3_bitrate: { elemId: "acestep-mp3-bitrate", type: "dropdown" },
|
||||||
|
mp3_sample_rate: { elemId: "acestep-mp3-sample-rate", type: "dropdown" },
|
||||||
|
score_scale: { elemId: "acestep-score-scale", type: "slider" },
|
||||||
|
enable_normalization:{ elemId: "acestep-enable-normalization", type: "checkbox" },
|
||||||
|
normalization_db: { elemId: "acestep-normalization-db", type: "slider" },
|
||||||
|
fade_in_duration: { elemId: "acestep-fade-in-duration", type: "slider" },
|
||||||
|
fade_out_duration: { elemId: "acestep-fade-out-duration", type: "slider" },
|
||||||
|
latent_shift: { elemId: "acestep-latent-shift", type: "slider" },
|
||||||
|
latent_rescale: { elemId: "acestep-latent-rescale", type: "slider" },
|
||||||
|
lm_batch_chunk_size: { elemId: "acestep-lm-batch-chunk-size", type: "number" },
|
||||||
|
};
|
||||||
|
|
||||||
|
let saveTimer = null;
|
||||||
|
const wiredElements = new WeakSet();
|
||||||
|
|
||||||
|
// ── Storage helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const saveAll = (prefs) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
} catch (_e) {
|
||||||
|
// Private browsing or quota exceeded – silently ignore.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── DOM helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const findInput = (elemId, type) => {
|
||||||
|
const wrapper = document.getElementById(elemId);
|
||||||
|
if (!wrapper) return null;
|
||||||
|
|
||||||
|
if (type === "dropdown") {
|
||||||
|
return wrapper.querySelector("input");
|
||||||
|
}
|
||||||
|
if (type === "slider") {
|
||||||
|
return wrapper.querySelector("input[type='range']")
|
||||||
|
|| wrapper.querySelector("input[type='number']");
|
||||||
|
}
|
||||||
|
if (type === "checkbox") {
|
||||||
|
return wrapper.querySelector("input[type='checkbox']");
|
||||||
|
}
|
||||||
|
if (type === "number") {
|
||||||
|
return wrapper.querySelector("input[type='number']");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readValue = (key) => {
|
||||||
|
const spec = PREFS[key];
|
||||||
|
if (!spec) return undefined;
|
||||||
|
const el = findInput(spec.elemId, spec.type);
|
||||||
|
if (!el) return undefined;
|
||||||
|
|
||||||
|
if (spec.type === "checkbox") return el.checked;
|
||||||
|
if (spec.type === "slider" || spec.type === "number") {
|
||||||
|
const v = Number(el.value);
|
||||||
|
return Number.isFinite(v) ? v : undefined;
|
||||||
|
}
|
||||||
|
return el.value || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Save (debounced) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const scheduleSave = () => {
|
||||||
|
if (saveTimer !== null) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
}
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
saveTimer = null;
|
||||||
|
const prefs = { _version: SCHEMA_VERSION };
|
||||||
|
for (const key of Object.keys(PREFS)) {
|
||||||
|
const v = readValue(key);
|
||||||
|
if (v !== undefined) {
|
||||||
|
prefs[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveAll(prefs);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Wire listeners (re-entrant – safe to call on re-renders) ─────
|
||||||
|
|
||||||
|
const wireListeners = () => {
|
||||||
|
for (const key of Object.keys(PREFS)) {
|
||||||
|
const spec = PREFS[key];
|
||||||
|
const el = findInput(spec.elemId, spec.type);
|
||||||
|
if (!el || wiredElements.has(el)) continue;
|
||||||
|
wiredElements.add(el);
|
||||||
|
el.addEventListener("input", scheduleSave, { passive: true });
|
||||||
|
el.addEventListener("change", scheduleSave, { passive: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── MutationObserver – re-wire after Gradio re-renders ───────────
|
||||||
|
|
||||||
|
const startObserver = () => {
|
||||||
|
const target = document.getElementById("acestep-audio-format")
|
||||||
|
|| document.body;
|
||||||
|
const root = target.closest(".gradio-container") || document.body;
|
||||||
|
|
||||||
|
let rafPending = false;
|
||||||
|
new MutationObserver(() => {
|
||||||
|
if (rafPending) return;
|
||||||
|
rafPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rafPending = false;
|
||||||
|
wireListeners();
|
||||||
|
});
|
||||||
|
}).observe(root, { childList: true, subtree: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Boot ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BOOT_POLL_MS = 200;
|
||||||
|
const BOOT_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
const boot = () => {
|
||||||
|
const started = Date.now();
|
||||||
|
const poll = () => {
|
||||||
|
const probe = document.getElementById(
|
||||||
|
PREFS.audio_format.elemId
|
||||||
|
);
|
||||||
|
if (!probe) {
|
||||||
|
if (Date.now() - started < BOOT_TIMEOUT_MS) {
|
||||||
|
setTimeout(poll, BOOT_POLL_MS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wireListeners();
|
||||||
|
startObserver();
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", boot, { once: true });
|
||||||
|
} else {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Frontend user-preference persistence helpers for the Gradio UI.
|
||||||
|
|
||||||
|
Save side: A ``<script>`` injected via ``Blocks(head=…)`` listens for DOM
|
||||||
|
changes and writes the current preference values to ``localStorage``.
|
||||||
|
|
||||||
|
Restore side: ``wire_preference_restore`` attaches a ``demo.load()`` handler
|
||||||
|
whose *js* parameter reads ``localStorage`` on page load and feeds the saved
|
||||||
|
values straight into the Gradio component outputs. Because Gradio itself
|
||||||
|
applies the updates through its own Svelte reactivity, every component type
|
||||||
|
(dropdown, slider, checkbox, number) is updated correctly—no fragile DOM
|
||||||
|
hacking required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_ASSET_FILENAME = "user_preferences.js"
|
||||||
|
_STORAGE_KEY = "acestep.ui.user_preferences"
|
||||||
|
_SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
# Ordered list of preference keys. The order here MUST match the order of
|
||||||
|
# *outputs* passed to ``demo.load()`` in ``wire_preference_restore``.
|
||||||
|
PREF_KEYS: list[str] = [
|
||||||
|
"audio_format",
|
||||||
|
"mp3_bitrate",
|
||||||
|
"mp3_sample_rate",
|
||||||
|
"score_scale",
|
||||||
|
"enable_normalization",
|
||||||
|
"normalization_db",
|
||||||
|
"fade_in_duration",
|
||||||
|
"fade_out_duration",
|
||||||
|
"latent_shift",
|
||||||
|
"latent_rescale",
|
||||||
|
"lm_batch_chunk_size",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Default values used when localStorage is empty or the schema version has
|
||||||
|
# changed. Keys must match ``PREF_KEYS``.
|
||||||
|
_DEFAULTS: dict[str, Any] = {
|
||||||
|
"audio_format": "flac",
|
||||||
|
"mp3_bitrate": "320k",
|
||||||
|
"mp3_sample_rate": 48000,
|
||||||
|
"score_scale": 0.5,
|
||||||
|
"enable_normalization": True,
|
||||||
|
"normalization_db": -1.0,
|
||||||
|
"fade_in_duration": 0.0,
|
||||||
|
"fade_out_duration": 0.0,
|
||||||
|
"latent_shift": 0.0,
|
||||||
|
"latent_rescale": 1.0,
|
||||||
|
"lm_batch_chunk_size": 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Save-side: head script injection ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _load_preferences_script() -> str:
|
||||||
|
"""Load the external save-preferences JavaScript asset."""
|
||||||
|
asset_path = Path(__file__).with_name(_ASSET_FILENAME)
|
||||||
|
return asset_path.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_preferences_head() -> str:
|
||||||
|
"""Return Gradio head HTML that injects save-side preference persistence."""
|
||||||
|
script_source = _load_preferences_script()
|
||||||
|
return f"<script>\n{script_source}\n</script>"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Restore-side: Gradio .load() wiring ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _build_restore_js(num_outputs: int) -> str:
|
||||||
|
"""Build the client-side JS that reads localStorage and returns values.
|
||||||
|
|
||||||
|
The returned function is passed as the ``js`` parameter to
|
||||||
|
``demo.load()``. It returns an array whose element order matches
|
||||||
|
``PREF_KEYS`` (and therefore the *outputs* list).
|
||||||
|
|
||||||
|
When localStorage has no saved preferences (first visit, cleared
|
||||||
|
storage, private browsing), the function returns an array of ``null``
|
||||||
|
sentinels so the Python side can skip the update and preserve whatever
|
||||||
|
values were already rendered from ``init_params``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
num_outputs: Total number of output components (preference keys
|
||||||
|
plus any extra outputs like ``mp3_controls_row``).
|
||||||
|
"""
|
||||||
|
keys_json = json.dumps(PREF_KEYS)
|
||||||
|
# Build a type map so the restore JS can validate each value.
|
||||||
|
type_map: dict[str, str] = {}
|
||||||
|
for k in PREF_KEYS:
|
||||||
|
v = _DEFAULTS[k]
|
||||||
|
if isinstance(v, bool):
|
||||||
|
type_map[k] = "boolean"
|
||||||
|
elif isinstance(v, (int, float)):
|
||||||
|
type_map[k] = "number"
|
||||||
|
else:
|
||||||
|
type_map[k] = "string"
|
||||||
|
type_map_json = json.dumps(type_map, ensure_ascii=False)
|
||||||
|
# Keys whose Gradio Dropdown choices are integers stored as strings in
|
||||||
|
# localStorage. Only actual dropdown keys with numeric defaults need
|
||||||
|
# coercion; sliders/numbers are already stored as numbers.
|
||||||
|
numeric_dropdown_keys_json = json.dumps(["mp3_sample_rate"])
|
||||||
|
# Sentinel array returned when there is nothing to restore. Using null
|
||||||
|
# lets the Python fn detect "no stored prefs" and return gr.update()
|
||||||
|
# for every output, preserving the values already rendered on the page.
|
||||||
|
skip_sentinel = f"new Array({num_outputs}).fill(null)"
|
||||||
|
return f"""() => {{
|
||||||
|
const STORAGE_KEY = {json.dumps(_STORAGE_KEY)};
|
||||||
|
const SCHEMA_VERSION = {_SCHEMA_VERSION};
|
||||||
|
const KEYS = {keys_json};
|
||||||
|
const TYPE_MAP = {type_map_json};
|
||||||
|
const NUMERIC_COERCE_KEYS = new Set({numeric_dropdown_keys_json});
|
||||||
|
const SKIP = {skip_sentinel};
|
||||||
|
try {{
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return SKIP;
|
||||||
|
const prefs = JSON.parse(raw);
|
||||||
|
// Only reset on downgrade; forward-compatible additions of new
|
||||||
|
// keys are handled by skipping (preserving init_params).
|
||||||
|
if (prefs._version !== SCHEMA_VERSION) {{
|
||||||
|
return SKIP;
|
||||||
|
}}
|
||||||
|
const result = KEYS.map(k => {{
|
||||||
|
if (!(k in prefs)) return null;
|
||||||
|
let v = prefs[k];
|
||||||
|
// Type-check: fall back to null (skip) if the stored type
|
||||||
|
// does not match what the Gradio component expects.
|
||||||
|
const expected = TYPE_MAP[k];
|
||||||
|
if (expected && typeof v !== expected) {{
|
||||||
|
// Allow stringified numbers for dropdown coercion below.
|
||||||
|
if (!(NUMERIC_COERCE_KEYS.has(k) && typeof v === "string")) {{
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
// Coerce stringified numbers back for Dropdown choices that
|
||||||
|
// expect integers (e.g. mp3_sample_rate: 48000 not "48000").
|
||||||
|
if (NUMERIC_COERCE_KEYS.has(k) && typeof v === "string") {{
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n)) v = n;
|
||||||
|
else return null;
|
||||||
|
}}
|
||||||
|
return v;
|
||||||
|
}});
|
||||||
|
// If none of the keys had stored values, skip entirely.
|
||||||
|
if (result.every(v => v === null)) return SKIP;
|
||||||
|
// Compute mp3 control visibility from audio_format (index 0).
|
||||||
|
// Push 3 extra values: mp3_controls_row, mp3_bitrate, mp3_sample_rate
|
||||||
|
// matching the outputs of _update_mp3_control_visibility().
|
||||||
|
// When audioFormat is null (no stored value), push nulls so Python
|
||||||
|
// emits gr.update() and preserves whatever init_params set.
|
||||||
|
const audioFormat = result[0];
|
||||||
|
const mp3 = audioFormat === null ? null : audioFormat === "mp3";
|
||||||
|
result.push(mp3, mp3, mp3);
|
||||||
|
return result;
|
||||||
|
}} catch (_e) {{
|
||||||
|
return SKIP;
|
||||||
|
}}
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
|
def restore_preferences(
|
||||||
|
*values: Any, _num_outputs: int = 0
|
||||||
|
) -> tuple[Any, ...]:
|
||||||
|
"""Map JS restore results into Gradio output values.
|
||||||
|
|
||||||
|
The JS function reads localStorage and produces an array:
|
||||||
|
- First ``len(PREF_KEYS)`` elements are preference values (or null).
|
||||||
|
- Next 3 elements are mp3 visibility booleans (or null):
|
||||||
|
[mp3_controls_row, mp3_bitrate, mp3_sample_rate].
|
||||||
|
|
||||||
|
``None`` (JSON ``null``) → ``gr.update()`` (no-op, preserves current).
|
||||||
|
Booleans beyond PREF_KEYS → visibility/interactivity updates matching
|
||||||
|
``_update_mp3_control_visibility()`` from the output controls module.
|
||||||
|
|
||||||
|
When the JS side returns no values (e.g. certain Gradio versions do not
|
||||||
|
forward the JS return value to the Python ``fn`` when ``inputs=None``),
|
||||||
|
``_num_outputs`` is used to produce the correct number of no-op updates
|
||||||
|
so Gradio does not raise a ``ValueError`` about mismatched output count.
|
||||||
|
"""
|
||||||
|
import gradio as gr
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return tuple(gr.update() for _ in range(_num_outputs))
|
||||||
|
|
||||||
|
n_prefs = len(PREF_KEYS)
|
||||||
|
results: list[Any] = []
|
||||||
|
for i, v in enumerate(values):
|
||||||
|
if v is None:
|
||||||
|
results.append(gr.update())
|
||||||
|
elif i == n_prefs and isinstance(v, bool):
|
||||||
|
# mp3_controls_row: visibility only.
|
||||||
|
results.append(gr.update(visible=v))
|
||||||
|
elif i > n_prefs and isinstance(v, bool):
|
||||||
|
# mp3_bitrate, mp3_sample_rate: visibility + interactivity.
|
||||||
|
results.append(gr.update(visible=v, interactive=v))
|
||||||
|
else:
|
||||||
|
results.append(v)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def wire_preference_restore(
|
||||||
|
demo: Any,
|
||||||
|
generation_section: dict[str, Any],
|
||||||
|
*,
|
||||||
|
service_mode: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Attach a ``demo.load()`` handler that restores saved preferences.
|
||||||
|
|
||||||
|
Must be called **inside** the ``with gr.Blocks() as demo:`` context,
|
||||||
|
after all generation components have been created.
|
||||||
|
|
||||||
|
In service mode the function is a no-op: service-mode sessions use
|
||||||
|
server-side ``init_params`` and controls are locked
|
||||||
|
(``interactive=False``), so localStorage values must not override them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
demo: The ``gr.Blocks`` instance.
|
||||||
|
generation_section: Merged component dict that includes the output
|
||||||
|
control components (``audio_format``, ``mp3_bitrate``, etc.).
|
||||||
|
service_mode: When ``True``, skip wiring entirely so that
|
||||||
|
localStorage cannot override server-configured values.
|
||||||
|
"""
|
||||||
|
if service_mode:
|
||||||
|
return
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
for key in PREF_KEYS:
|
||||||
|
component = generation_section.get(key)
|
||||||
|
if component is None:
|
||||||
|
raise KeyError(
|
||||||
|
f"wire_preference_restore: missing component {key!r} in "
|
||||||
|
f"generation_section (available: {sorted(generation_section)})"
|
||||||
|
)
|
||||||
|
outputs.append(component)
|
||||||
|
|
||||||
|
# Also update mp3 control visibility so it stays in sync when the
|
||||||
|
# restored audio_format differs from the server-rendered default.
|
||||||
|
# Gradio does not fire .change() for load-time value assignments, so
|
||||||
|
# without this the MP3 row and its children could be visible/hidden
|
||||||
|
# incorrectly. The three extra outputs mirror the return of
|
||||||
|
# _update_mp3_control_visibility(): [row, bitrate, sample_rate].
|
||||||
|
for mp3_key in ("mp3_controls_row", "mp3_bitrate", "mp3_sample_rate"):
|
||||||
|
comp = generation_section.get(mp3_key)
|
||||||
|
if comp is not None:
|
||||||
|
outputs.append(comp)
|
||||||
|
|
||||||
|
demo.load(
|
||||||
|
fn=partial(restore_preferences, _num_outputs=len(outputs)),
|
||||||
|
inputs=None,
|
||||||
|
outputs=outputs,
|
||||||
|
js=_build_restore_js(num_outputs=len(outputs)),
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FROM ghcr.io/ace-step/ace-step-1.5:latest@sha256:95652cd780c78a1b1a7f6f0335530430f0ae53d96c7c12d59f9f39fa23d38567
|
||||||
|
|
||||||
|
COPY patch-api-routes.py /tmp/patch-api-routes.py
|
||||||
|
RUN /usr/bin/python3 /tmp/patch-api-routes.py \
|
||||||
|
/app/acestep/ui/gradio/api/api_routes.py \
|
||||||
|
&& rm /tmp/patch-api-routes.py
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Extend ACE-Step's official /release_task route with named generation inputs.
|
||||||
|
|
||||||
|
The base image already provides the route. This build-time patch only exposes
|
||||||
|
the parameters supported by its installed GenerationParams/GenerationConfig
|
||||||
|
dataclasses, so the separate Community UI never has to depend on Gradio's
|
||||||
|
positional component order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
target = Path(sys.argv[1])
|
||||||
|
source = target.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def replace_once(old: str, new: str, label: str) -> None:
|
||||||
|
global source
|
||||||
|
count = source.count(old)
|
||||||
|
if count != 1:
|
||||||
|
raise RuntimeError(f"{label}: expected one anchor, found {count}")
|
||||||
|
source = source.replace(old, new, 1)
|
||||||
|
|
||||||
|
|
||||||
|
old_params = ''' # Build generation params with alias support
|
||||||
|
params = GenerationParams(
|
||||||
|
task_type=get_param("task_type", default="text2music"),
|
||||||
|
caption=caption,
|
||||||
|
lyrics=lyrics,
|
||||||
|
bpm=sample_bpm or get_param("bpm"),
|
||||||
|
keyscale=sample_keyscale or get_param("key_scale", "keyscale", "key", default=""),
|
||||||
|
timesignature=sample_timesignature or get_param("time_signature", "timesignature", default=""),
|
||||||
|
duration=sample_duration or get_param("audio_duration", "duration", default=-1),
|
||||||
|
vocal_language=sample_language,
|
||||||
|
inference_steps=get_param("inference_steps", default=8),
|
||||||
|
guidance_scale=float(get_param("guidance_scale", default=7.0) or 7.0),
|
||||||
|
seed=int(get_param("seed", default=-1) or -1),
|
||||||
|
thinking=to_bool(get_param("thinking"), False),
|
||||||
|
lm_temperature=lm_temperature,
|
||||||
|
lm_cfg_scale=float(get_param("lm_cfg_scale", default=2.0) or 2.0),
|
||||||
|
lm_negative_prompt=get_param("lm_negative_prompt", default="NO USER INPUT") or "NO USER INPUT",
|
||||||
|
repaint_latent_crossfade_frames=int(
|
||||||
|
get_param("repaint_latent_crossfade_frames", default=10) or 10,
|
||||||
|
),
|
||||||
|
repaint_wav_crossfade_sec=float(
|
||||||
|
get_param("repaint_wav_crossfade_sec", default=0.0) or 0.0,
|
||||||
|
),
|
||||||
|
repaint_mode=get_param("repaint_mode", default="balanced") or "balanced",
|
||||||
|
repaint_strength=float(
|
||||||
|
get_param("repaint_strength", default=0.5) or 0.5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
|
||||||
|
new_params = ''' # Build generation params with alias support. Keep this
|
||||||
|
# mapping explicit: every public API field below is named and independent
|
||||||
|
# from the order of components in the Gradio interface.
|
||||||
|
raw_bpm = sample_bpm or get_param("bpm")
|
||||||
|
params = GenerationParams(
|
||||||
|
task_type=get_param("task_type", default="text2music") or "text2music",
|
||||||
|
instruction=get_param("instruction", default="Fill the audio semantic mask based on the given conditions:") or "Fill the audio semantic mask based on the given conditions:",
|
||||||
|
reference_audio=get_param("reference_audio_path", "reference_audio"),
|
||||||
|
src_audio=get_param("src_audio_path", "src_audio", "source_audio"),
|
||||||
|
audio_codes=get_param("audio_codes", default="") or "",
|
||||||
|
caption=caption,
|
||||||
|
lyrics=lyrics,
|
||||||
|
instrumental=to_bool(get_param("instrumental"), False),
|
||||||
|
bpm=int(float(raw_bpm)) if raw_bpm not in (None, "", 0, "0") else None,
|
||||||
|
keyscale=sample_keyscale or get_param("key_scale", "keyscale", "key", default=""),
|
||||||
|
timesignature=sample_timesignature or get_param("time_signature", "timesignature", default=""),
|
||||||
|
duration=float(sample_duration or get_param("audio_duration", "duration", default=-1) or -1),
|
||||||
|
vocal_language=sample_language,
|
||||||
|
inference_steps=int(get_param("inference_steps", default=50) or 50),
|
||||||
|
guidance_scale=float(get_param("guidance_scale", default=7.0) or 7.0),
|
||||||
|
seed=int(get_param("seed", default=-1) or -1),
|
||||||
|
use_adg=to_bool(get_param("use_adg"), False),
|
||||||
|
cfg_interval_start=float(get_param("cfg_interval_start", default=0.0) or 0.0),
|
||||||
|
cfg_interval_end=float(get_param("cfg_interval_end", default=1.0) or 1.0),
|
||||||
|
shift=float(get_param("shift", default=1.0) or 1.0),
|
||||||
|
infer_method=get_param("infer_method", default="ode") or "ode",
|
||||||
|
sampler_mode=get_param("sampler_mode", default="euler") or "euler",
|
||||||
|
repainting_start=float(get_param("repainting_start", default=0.0) or 0.0),
|
||||||
|
repainting_end=float(get_param("repainting_end", default=-1.0) or -1.0),
|
||||||
|
chunk_mask_mode=get_param("chunk_mask_mode", default="auto") or "auto",
|
||||||
|
audio_cover_strength=float(get_param("audio_cover_strength", default=1.0) or 1.0),
|
||||||
|
cover_noise_strength=float(get_param("cover_noise_strength", default=0.0) or 0.0),
|
||||||
|
thinking=to_bool(get_param("thinking"), True),
|
||||||
|
lm_temperature=lm_temperature,
|
||||||
|
lm_cfg_scale=float(get_param("lm_cfg_scale", default=2.0) or 2.0),
|
||||||
|
lm_top_k=int(get_param("lm_top_k", default=0) or 0),
|
||||||
|
lm_top_p=float(get_param("lm_top_p", default=0.9) or 0.9),
|
||||||
|
lm_negative_prompt=get_param("lm_negative_prompt", default="NO USER INPUT") or "NO USER INPUT",
|
||||||
|
use_cot_metas=to_bool(get_param("use_cot_metas"), True),
|
||||||
|
use_cot_caption=to_bool(get_param("use_cot_caption"), True),
|
||||||
|
use_cot_lyrics=to_bool(get_param("use_cot_lyrics"), False),
|
||||||
|
use_cot_language=to_bool(get_param("use_cot_language"), True),
|
||||||
|
use_constrained_decoding=to_bool(get_param("use_constrained_decoding"), True),
|
||||||
|
enable_normalization=to_bool(get_param("enable_normalization"), True),
|
||||||
|
normalization_db=float(get_param("normalization_db", default=-1.0) or -1.0),
|
||||||
|
fade_in_duration=float(get_param("fade_in_duration", default=0.0) or 0.0),
|
||||||
|
fade_out_duration=float(get_param("fade_out_duration", default=0.0) or 0.0),
|
||||||
|
latent_shift=float(get_param("latent_shift", default=0.0) or 0.0),
|
||||||
|
latent_rescale=float(get_param("latent_rescale", default=1.0) or 1.0),
|
||||||
|
repaint_latent_crossfade_frames=int(get_param("repaint_latent_crossfade_frames", default=10) or 10),
|
||||||
|
repaint_wav_crossfade_sec=float(get_param("repaint_wav_crossfade_sec", default=0.0) or 0.0),
|
||||||
|
repaint_mode=get_param("repaint_mode", default="balanced") or "balanced",
|
||||||
|
repaint_strength=float(get_param("repaint_strength", default=0.5) or 0.5),
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
|
||||||
|
replace_once(old_params, new_params, "GenerationParams mapping")
|
||||||
|
|
||||||
|
old_config = ''' config = GenerationConfig(
|
||||||
|
batch_size=get_param("batch_size", default=2),
|
||||||
|
use_random_seed=use_random_seed,
|
||||||
|
seeds=resolved_seeds,
|
||||||
|
audio_format=get_param("audio_format", default="flac"),
|
||||||
|
mp3_bitrate=get_param("mp3_bitrate", default="128k"),
|
||||||
|
mp3_sample_rate=get_param("mp3_sample_rate", default=48000),
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
|
||||||
|
new_config = ''' config = GenerationConfig(
|
||||||
|
batch_size=int(get_param("batch_size", default=1) or 1),
|
||||||
|
allow_lm_batch=to_bool(get_param("allow_lm_batch"), True),
|
||||||
|
use_random_seed=to_bool(use_random_seed, True),
|
||||||
|
seeds=resolved_seeds,
|
||||||
|
lm_batch_chunk_size=int(get_param("lm_batch_chunk_size", default=8) or 8),
|
||||||
|
constrained_decoding_debug=to_bool(get_param("constrained_decoding_debug"), False),
|
||||||
|
audio_format=get_param("audio_format", default="flac") or "flac",
|
||||||
|
mp3_bitrate=get_param("mp3_bitrate", default="320k") or "320k",
|
||||||
|
mp3_sample_rate=int(get_param("mp3_sample_rate", default=48000) or 48000),
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
|
||||||
|
replace_once(old_config, new_config, "GenerationConfig mapping")
|
||||||
|
target.write_text(source, encoding="utf-8")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM python:3.12-trixie
|
||||||
|
|
||||||
|
ARG APPLIO_COMMIT=7fa68ec2166ab1331c539704159fa14901e94e5a
|
||||||
|
ENV PATH=/app/.venv/bin:$PATH \
|
||||||
|
HF_HOME=/models/huggingface \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl ffmpeg git libportaudio2 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN git clone https://github.com/IAHispano/Applio.git . \
|
||||||
|
&& git checkout "$APPLIO_COMMIT" \
|
||||||
|
&& python3 -m venv /app/.venv \
|
||||||
|
&& pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir python-ffmpeg \
|
||||||
|
&& pip install --no-cache-dir torch==2.7.1 torchvision==0.22.1 torchaudio==2.7.1 \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu128 \
|
||||||
|
&& sed -i '/^torch==/d;/^torchvision==/d;/^torchaudio==/d' requirements.txt \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt \
|
||||||
|
&& pip install --no-cache-dir "websockets>=13.0"
|
||||||
|
|
||||||
|
EXPOSE 6969
|
||||||
|
CMD ["python3", "app.py", "--server-name", "0.0.0.0", "--port", "6969"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Applio / RVC Studio
|
||||||
|
|
||||||
|
Reproduzierbarer, experimenteller Applio-Worker mit der offiziellen
|
||||||
|
Weboberfläche. Der Build ist auf Upstream-Commit
|
||||||
|
`7fa68ec2166ab1331c539704159fa14901e94e5a` festgeschrieben.
|
||||||
|
|
||||||
|
- Dashboard-Modus: `Applio / RVC`
|
||||||
|
- WireGuard-URL: `http://192.168.1.212:8011/`
|
||||||
|
- GPU: RTX 5080, exklusiv zu LLM, Musik- und anderen Voice-Modi
|
||||||
|
- Persistenz: Basisgewichte, importierte/trainierte Modelle, Konfiguration,
|
||||||
|
Logs und Hugging-Face-Cache unter `/data/voice/applio`
|
||||||
|
|
||||||
|
Applio stellt die RVC-Werkzeuge und deren Oberfläche bereit. Eine konkrete
|
||||||
|
Zielstimme wird anschließend in der Oberfläche importiert oder trainiert.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
services:
|
||||||
|
applio-studio:
|
||||||
|
build: .
|
||||||
|
image: mike-ai/applio-studio:7fa68ec
|
||||||
|
container_name: mike-ai-applio-studio
|
||||||
|
restart: "no"
|
||||||
|
labels:
|
||||||
|
com.mike-ai.applio-worker: applio
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
HF_HOME: /models/huggingface
|
||||||
|
PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8011:6969"
|
||||||
|
volumes:
|
||||||
|
- /data/voice/applio/huggingface:/models/huggingface
|
||||||
|
- /data/voice/applio/logs:/app/logs
|
||||||
|
- /data/voice/applio/models:/app/rvc/models
|
||||||
|
- /data/voice/applio/config.json:/app/assets/config.json
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:6969/ >/dev/null"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 900s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
aliases: [applio-studio]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
name: mike-ai_frontend
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
FROM pytorch/pytorch:2.7.1-cuda12.8-cudnn9-runtime@sha256:c16f4c749e2d9e96878875cdf6cc45cddda1d1a36fddd371dd6f2360f1b6e2a2
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential curl ffmpeg libsndfile1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python -m pip install --no-cache-dir \
|
||||||
|
"audio-separator[gpu]==0.47.0" \
|
||||||
|
"onnxruntime-gpu==1.22.0" \
|
||||||
|
"fastapi==0.116.1" \
|
||||||
|
"python-multipart==0.0.20" \
|
||||||
|
"uvicorn[standard]==0.35.0"
|
||||||
|
|
||||||
|
# audio-separator 0.47 requires NumPy 2 while ClearVoice 0.1.2 still pins
|
||||||
|
# NumPy 1.x. Keep ClearVoice in a small overlay venv but share the image's
|
||||||
|
# CUDA-enabled PyTorch installation instead of duplicating it.
|
||||||
|
RUN python -m venv --system-site-packages /opt/clearvoice-venv \
|
||||||
|
&& /opt/clearvoice-venv/bin/python -m pip install --no-cache-dir \
|
||||||
|
"clearvoice==0.1.2" \
|
||||||
|
"numpy>=1.24.3,<2.0"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app.py index.html speech_enhance.py ./
|
||||||
|
|
||||||
|
ENV MODEL_FILENAME=model_bs_roformer_ep_317_sdr_12.9755.ckpt \
|
||||||
|
MODEL_DIR=/models \
|
||||||
|
JOB_DIR=/data/jobs
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["sh", "-c", "mkdir -p \"$MODEL_DIR/clearvoice\" && ln -sfn \"$MODEL_DIR/clearvoice\" /app/checkpoints && for model in \"$MODEL_FILENAME\" htdemucs_ft.yaml htdemucs_6s.yaml; do audio-separator --model_filename \"$model\" --model_file_dir \"$MODEL_DIR\" --download_model_only || exit 1; done; /opt/clearvoice-venv/bin/python /app/speech_enhance.py --download-only || exit 1; exec uvicorn app:app --host 0.0.0.0 --port 8080 --workers 1"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Athena Stem Separator
|
||||||
|
|
||||||
|
Exklusiver dritter Athena-Betriebsmodus zum gezielten Herauslösen einer Quelle.
|
||||||
|
Der Download enthält immer die Zielspur und eine zweite Spur mit dem kompletten
|
||||||
|
Rest ohne dieses Ziel.
|
||||||
|
|
||||||
|
- Engine: `audio-separator` 0.47.0 (MIT)
|
||||||
|
- **Gesang / Instrumental:** BS-RoFormer Viperx 1297,
|
||||||
|
`model_bs_roformer_ep_317_sdr_12.9755.ckpt`; Vocal SDR 12,9,
|
||||||
|
Instrumental SDR 17,0.
|
||||||
|
- **Schlagzeug oder Bass:** `htdemucs_ft.yaml`; die nicht gewählten Stems werden
|
||||||
|
zu einer gemeinsamen Restspur summiert.
|
||||||
|
- **Gitarre oder Piano (experimentell):** `htdemucs_6s.yaml`; auch hier werden
|
||||||
|
alle übrigen Stems wieder zur Restspur zusammengesetzt. Die Instrumentqualität
|
||||||
|
liegt unter der spezialisierten Gesangstrennung.
|
||||||
|
- **Sonstiges:** der `other`-Stem von `htdemucs_6s.yaml`. Er bündelt unter anderem
|
||||||
|
Synthesizer, Streicher, Bläser und Effekte und ist keine reine Synthesizer-Spur.
|
||||||
|
- **Sprache / Hintergrund:** ClearVoice `MossFormer2_SE_48K` (Apache-2.0)
|
||||||
|
verbessert Sprache bei 48 kHz. Die zweite Spur ist das vom Originalsignal
|
||||||
|
abgezogene Sprachsignal und enthält den verbleibenden Hintergrund. Stereo wird
|
||||||
|
kanalweise verarbeitet und anschließend wieder zusammengesetzt.
|
||||||
|
- GPU: RTX 5080; LLM, Bildmodelle, TTS und ACE-Step sind dabei verriegelt.
|
||||||
|
- Privat erreichbar: `http://192.168.1.212:8007/`
|
||||||
|
|
||||||
|
Die Modelle werden beim ersten Start nach `/data/models/audio-separator`
|
||||||
|
heruntergeladen. Temporäre Jobs liegen unter `/data/audio/separation` und
|
||||||
|
werden nach dem ZIP-Download entfernt. Eigene Spuren für E-/Akustikgitarre,
|
||||||
|
Synthesizer und Streicher sind bewusst noch nicht angeboten: Dafür braucht es
|
||||||
|
weitere Zielmodelle. Die Oberfläche bietet stattdessen den ehrlich benannten,
|
||||||
|
gemischten `other`-Stem als **Sonstiges** an.
|
||||||
|
|
||||||
|
Die API erwartet `multipart/form-data` mit `file` und optional `target`:
|
||||||
|
`vocals` (Standard), `drums`, `bass`, `guitar`, `piano`, `other` oder `speech`. Das ältere Feld
|
||||||
|
`mode` mit `vocals`, `four_stem` oder `six_stem` bleibt für vorhandene Clients
|
||||||
|
erhalten und liefert weiterhin alle Modell-Stems.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
|
|
||||||
|
MODEL = os.getenv("MODEL_FILENAME", "model_bs_roformer_ep_317_sdr_12.9755.ckpt")
|
||||||
|
MODEL_DIR = Path(os.getenv("MODEL_DIR", "/models"))
|
||||||
|
JOB_DIR = Path(os.getenv("JOB_DIR", "/data/jobs"))
|
||||||
|
MAX_UPLOAD = int(os.getenv("MAX_UPLOAD_BYTES", str(1024 ** 3)))
|
||||||
|
ALLOWED = {".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wma"}
|
||||||
|
SEPARATION_LOCK = asyncio.Lock()
|
||||||
|
STARTED = time.time()
|
||||||
|
|
||||||
|
MODES = {
|
||||||
|
"vocals": {"model": MODEL, "stems": ("vocals", "instrumental"), "archive": "athena-vocals-instrumental.zip", "engine": "mdxc"},
|
||||||
|
"four_stem": {"model": "htdemucs_ft.yaml", "stems": ("vocals", "drums", "bass", "other"), "archive": "athena-4-stems.zip", "engine": "demucs"},
|
||||||
|
"six_stem": {"model": "htdemucs_6s.yaml", "stems": ("vocals", "drums", "bass", "guitar", "piano", "other"), "archive": "athena-6-stems-experimental.zip", "engine": "demucs"},
|
||||||
|
"speech": {"model": "MossFormer2_SE_48K", "stems": ("speech", "noise"), "archive": "athena-sprache-und-hintergrund.zip", "engine": "clearvoice"},
|
||||||
|
}
|
||||||
|
TARGETS = {
|
||||||
|
"vocals": {"mode": "vocals", "stem": "vocals", "remainder": "instrumental", "archive": "athena-gesang-und-rest.zip", "rest_file": "instrumental.flac"},
|
||||||
|
"drums": {"mode": "four_stem", "stem": "drums", "archive": "athena-schlagzeug-und-rest.zip", "rest_file": "rest-ohne-schlagzeug.flac"},
|
||||||
|
"bass": {"mode": "four_stem", "stem": "bass", "archive": "athena-bass-und-rest.zip", "rest_file": "rest-ohne-bass.flac"},
|
||||||
|
"guitar": {"mode": "six_stem", "stem": "guitar", "archive": "athena-gitarre-und-rest.zip", "rest_file": "rest-ohne-gitarre.flac"},
|
||||||
|
"piano": {"mode": "six_stem", "stem": "piano", "archive": "athena-piano-und-rest.zip", "rest_file": "rest-ohne-piano.flac"},
|
||||||
|
"other": {"mode": "six_stem", "stem": "other", "archive": "athena-sonstiges-und-rest.zip", "rest_file": "rest-ohne-sonstiges.flac"},
|
||||||
|
"speech": {"mode": "speech", "stem": "speech", "remainder": "noise", "archive": "athena-sprache-und-hintergrund.zip", "rest_file": "hintergrund-ohne-sprache.flac"},
|
||||||
|
}
|
||||||
|
|
||||||
|
app = FastAPI(title="Athena Stem Separator", version="2.0")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index() -> str:
|
||||||
|
return Path("/app/index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
available = {
|
||||||
|
name: (
|
||||||
|
(MODEL_DIR / "clearvoice" / mode["model"] / "last_best_checkpoint").exists()
|
||||||
|
if mode["engine"] == "clearvoice"
|
||||||
|
else (MODEL_DIR / mode["model"]).exists()
|
||||||
|
)
|
||||||
|
for name, mode in MODES.items()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "ok" if all(available.values()) else "starting",
|
||||||
|
"models": {name: mode["model"] for name, mode in MODES.items()},
|
||||||
|
"models_ready": available,
|
||||||
|
"targets": list(TARGETS),
|
||||||
|
"busy": SEPARATION_LOCK.locked(),
|
||||||
|
"uptime_seconds": round(time.time() - STARTED, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup(path: Path) -> None:
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_separator(input_path: Path, output_dir: Path, mode: dict) -> None:
|
||||||
|
if mode["engine"] == "clearvoice":
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
"/opt/clearvoice-venv/bin/python", "/app/speech_enhance.py", str(input_path),
|
||||||
|
str(output_dir / "speech.flac"), str(output_dir / "noise.flac"),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=7200,
|
||||||
|
)
|
||||||
|
if completed.returncode:
|
||||||
|
detail = (completed.stderr or completed.stdout or "unknown ClearVoice error")[-4000:]
|
||||||
|
raise RuntimeError(detail)
|
||||||
|
return
|
||||||
|
args = [
|
||||||
|
"audio-separator", str(input_path),
|
||||||
|
"--model_filename", mode["model"],
|
||||||
|
"--model_file_dir", str(MODEL_DIR),
|
||||||
|
"--output_dir", str(output_dir),
|
||||||
|
"--output_format", "FLAC",
|
||||||
|
"--sample_rate", "44100",
|
||||||
|
"--use_autocast",
|
||||||
|
]
|
||||||
|
if mode["engine"] == "mdxc":
|
||||||
|
args.extend(["--mdxc_segment_size", "256", "--mdxc_overlap", "8", "--mdxc_batch_size", "1"])
|
||||||
|
else:
|
||||||
|
args.extend(["--demucs_segment_size", "40", "--demucs_shifts", "2", "--demucs_overlap", "0.25"])
|
||||||
|
completed = subprocess.run(args, capture_output=True, text=True, timeout=7200)
|
||||||
|
if completed.returncode:
|
||||||
|
detail = (completed.stderr or completed.stdout or "unknown error")[-4000:]
|
||||||
|
raise RuntimeError(detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _stem_name(path: Path, expected: tuple[str, ...]) -> str | None:
|
||||||
|
lower = path.stem.lower()
|
||||||
|
for stem in sorted(expected, key=len, reverse=True):
|
||||||
|
if stem in lower:
|
||||||
|
return stem
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mix_remainder(stems: list[Path], output_path: Path) -> None:
|
||||||
|
args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
|
||||||
|
for stem in stems:
|
||||||
|
args.extend(["-i", str(stem)])
|
||||||
|
inputs = "".join(f"[{index}:a]" for index in range(len(stems)))
|
||||||
|
args.extend([
|
||||||
|
"-filter_complex", f"{inputs}amix=inputs={len(stems)}:normalize=0:dropout_transition=0[rest]",
|
||||||
|
"-map", "[rest]", "-ar", "44100", "-c:a", "flac", str(output_path),
|
||||||
|
])
|
||||||
|
completed = subprocess.run(args, capture_output=True, text=True, timeout=1800)
|
||||||
|
if completed.returncode:
|
||||||
|
detail = (completed.stderr or completed.stdout or "unknown ffmpeg error")[-4000:]
|
||||||
|
raise RuntimeError(f"Restspur konnte nicht erzeugt werden: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/separate")
|
||||||
|
async def separate(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
target: str | None = Form(None),
|
||||||
|
mode: str | None = Form(None),
|
||||||
|
) -> FileResponse:
|
||||||
|
selected_target = TARGETS.get(target) if target else None
|
||||||
|
if target and selected_target is None:
|
||||||
|
raise HTTPException(422, f"Unbekannte Zielspur: {target}")
|
||||||
|
selected_mode_name = selected_target["mode"] if selected_target else (mode or "vocals")
|
||||||
|
selected_mode = MODES.get(selected_mode_name)
|
||||||
|
if selected_mode is None:
|
||||||
|
raise HTTPException(422, f"Unbekannter Trennmodus: {selected_mode_name}")
|
||||||
|
suffix = Path(file.filename or "upload.wav").suffix.lower()
|
||||||
|
if suffix not in ALLOWED:
|
||||||
|
raise HTTPException(415, "Dieses Audioformat wird nicht unterstützt.")
|
||||||
|
if SEPARATION_LOCK.locked():
|
||||||
|
raise HTTPException(409, "Eine Trennung läuft bereits.")
|
||||||
|
|
||||||
|
job = Path(tempfile.mkdtemp(prefix="separate-", dir=JOB_DIR))
|
||||||
|
input_path = job / f"input{suffix}"
|
||||||
|
output_dir = job / "output"
|
||||||
|
output_dir.mkdir()
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
with input_path.open("wb") as handle:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_UPLOAD:
|
||||||
|
raise HTTPException(413, "Datei ist größer als 1 GiB.")
|
||||||
|
handle.write(chunk)
|
||||||
|
async with SEPARATION_LOCK:
|
||||||
|
await asyncio.to_thread(_run_separator, input_path, output_dir, selected_mode)
|
||||||
|
|
||||||
|
stems = sorted(output_dir.glob("*.flac"))
|
||||||
|
expected = selected_mode["stems"]
|
||||||
|
recognized = {_stem_name(stem, expected): stem for stem in stems}
|
||||||
|
recognized.pop(None, None)
|
||||||
|
missing = [stem for stem in expected if stem not in recognized]
|
||||||
|
if missing:
|
||||||
|
found = ", ".join(stem.name for stem in stems) or "keine"
|
||||||
|
raise RuntimeError(f"Fehlende Spuren: {', '.join(missing)}; gefunden: {found}")
|
||||||
|
archive_name = selected_target["archive"] if selected_target else selected_mode["archive"]
|
||||||
|
archive = job / archive_name
|
||||||
|
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as bundle:
|
||||||
|
if selected_target:
|
||||||
|
target_stem = selected_target["stem"]
|
||||||
|
bundle.write(recognized[target_stem], f"{target_stem}.flac")
|
||||||
|
if "remainder" in selected_target:
|
||||||
|
remainder = recognized[selected_target["remainder"]]
|
||||||
|
else:
|
||||||
|
remainder = job / selected_target["rest_file"]
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_mix_remainder,
|
||||||
|
[recognized[stem] for stem in expected if stem != target_stem],
|
||||||
|
remainder,
|
||||||
|
)
|
||||||
|
bundle.write(remainder, selected_target["rest_file"])
|
||||||
|
else:
|
||||||
|
# Rückwärtskompatibilität für bestehende API-Clients.
|
||||||
|
for stem in expected:
|
||||||
|
bundle.write(recognized[stem], f"{stem}.flac")
|
||||||
|
return FileResponse(
|
||||||
|
archive,
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=archive_name,
|
||||||
|
background=BackgroundTask(_cleanup, job),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
_cleanup(job)
|
||||||
|
raise
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
_cleanup(job)
|
||||||
|
raise HTTPException(504, "Die Trennung hat das Zeitlimit überschritten.")
|
||||||
|
except Exception as exc:
|
||||||
|
_cleanup(job)
|
||||||
|
raise HTTPException(500, f"Trennung fehlgeschlagen: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def prepare() -> None:
|
||||||
|
JOB_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
for old in JOB_DIR.glob("separate-*"):
|
||||||
|
if old.is_dir() and time.time() - old.stat().st_mtime > 86400:
|
||||||
|
_cleanup(old)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
stem-separator:
|
||||||
|
build: .
|
||||||
|
image: mike-ai/bs-roformer-separator:0.47.0
|
||||||
|
container_name: mike-ai-stem-separator
|
||||||
|
labels:
|
||||||
|
com.mike-ai.stem-separator: "bs-roformer"
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${SEPARATOR_GPU_UUID:?set SEPARATOR_GPU_UUID to the RTX 5080 UUID}
|
||||||
|
MODEL_FILENAME: model_bs_roformer_ep_317_sdr_12.9755.ckpt
|
||||||
|
MODEL_DIR: /models
|
||||||
|
JOB_DIR: /data/jobs
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["${SEPARATOR_GPU_UUID:?set SEPARATOR_GPU_UUID to the RTX 5080 UUID}"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${SEPARATOR_PORT:-8007}:8080"
|
||||||
|
volumes:
|
||||||
|
- ${SEPARATOR_MODEL_DIR:-/data/models/audio-separator}:/models
|
||||||
|
- ${SEPARATOR_DATA_DIR:-/data/audio/separation}:/data
|
||||||
|
shm_size: "2gb"
|
||||||
|
restart: "no"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/health | grep -q '\"status\":\"ok\"'"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 600s
|
||||||
|
retries: 3
|
||||||
|
networks: [frontend]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
external: true
|
||||||
|
name: mike-ai_frontend
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Athena · Spuren herauslösen</title><style>
|
||||||
|
:root{color-scheme:dark;--bg:#07111c;--card:#101d2b;--line:#26384b;--cyan:#48d7f5;--mint:#63e6be;--text:#ecf5ff;--muted:#91a4b7}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#142a42 0,#07111c 42%);font:16px system-ui,sans-serif;color:var(--text);min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(880px,100%);padding:32px;border:1px solid var(--line);border-radius:22px;background:rgba(16,29,43,.96);box-shadow:0 25px 70px #0008}.eyebrow{color:var(--cyan);font-weight:800;letter-spacing:.14em;text-transform:uppercase;font-size:12px}h1{font-size:clamp(30px,5vw,52px);margin:.3em 0 .15em}p{color:var(--muted);line-height:1.6}.targets{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin:24px 0}.target{display:block;border:1px solid var(--line);border-radius:14px;padding:14px 10px;text-align:center;cursor:pointer}.target:has(input:checked){border-color:var(--cyan);background:#48d7f510;box-shadow:0 0 0 1px #48d7f528}.target input{display:none}.target b,.target span{display:block}.target span{color:var(--muted);font-size:12px;margin-top:5px;line-height:1.35}.section{grid-column:1/-1;color:var(--cyan);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;margin-top:8px}.drop{display:block;margin:20px 0;padding:36px 24px;border:2px dashed #3f5a72;border-radius:18px;text-align:center;cursor:pointer;transition:.2s}.drop:hover,.drop.drag{border-color:var(--cyan);background:#48d7f50b}.drop input{display:none}.file{color:var(--mint);font-weight:700;margin-top:8px}button{width:100%;border:0;border-radius:13px;padding:15px;font-weight:800;font-size:16px;background:linear-gradient(90deg,var(--cyan),var(--mint));color:#05202a;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.status{min-height:28px;margin-top:18px;color:var(--muted)}.bar{height:7px;background:#07111c;border-radius:9px;overflow:hidden;margin-top:12px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--mint));transition:.4s}.run .fill{width:85%;animation:pulse 1.5s infinite alternate}@keyframes pulse{to{opacity:.45}}small{display:block;color:#71879a;margin-top:20px}@media(max-width:760px){.targets{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
</style></head><body><main class="card"><div class="eyebrow">Athena Audio Lab</div><h1>Was möchtest du herauslösen?</h1><p>Der Download enthält immer die gewählte Spur separat und zusätzlich den vollständigen Rest ohne diese Spur.</p>
|
||||||
|
<div class="targets">
|
||||||
|
<div class="section">Musik</div>
|
||||||
|
<label class="target"><input type="radio" name="target" value="vocals" checked><b>Gesang</b><span>BS‑RoFormer<br>beste Qualität</span></label>
|
||||||
|
<label class="target"><input type="radio" name="target" value="drums"><b>Schlagzeug</b><span>HTDemucs FT</span></label>
|
||||||
|
<label class="target"><input type="radio" name="target" value="bass"><b>Bass</b><span>HTDemucs FT</span></label>
|
||||||
|
<label class="target"><input type="radio" name="target" value="guitar"><b>Gitarre</b><span>HTDemucs 6s<br>experimentell</span></label>
|
||||||
|
<label class="target"><input type="radio" name="target" value="piano"><b>Piano</b><span>HTDemucs 6s<br>experimentell</span></label>
|
||||||
|
<label class="target"><input type="radio" name="target" value="other"><b>Sonstiges</b><span>Synths, Streicher etc.<br>gemischte Spur</span></label>
|
||||||
|
<div class="section">Sprache und Geräusche</div>
|
||||||
|
<label class="target"><input type="radio" name="target" value="speech"><b>Sprache reinigen</b><span>MossFormer2 · 48 kHz<br>Sprache + Hintergrund</span></label>
|
||||||
|
</div>
|
||||||
|
<label class="drop" id="drop">Audio auswählen oder hier ablegen<input id="file" type="file" accept="audio/*"><div class="file" id="name">Noch keine Datei gewählt</div></label><button id="start" disabled>Ausgewählte Spur und Rest erzeugen</button><div class="status" id="status">Bereit.</div><div class="bar" id="bar"><div class="fill"></div></div><small>Alles läuft lokal auf Athena. Synthesizer, Streicher sowie elektrische und akustische Gitarre separat benötigen zusätzliche Spezialmodelle.</small></main><script>
|
||||||
|
const file=document.querySelector('#file'),drop=document.querySelector('#drop'),name=document.querySelector('#name'),start=document.querySelector('#start'),status=document.querySelector('#status'),bar=document.querySelector('#bar');let selected;const names={vocals:'gesang',drums:'schlagzeug',bass:'bass',guitar:'gitarre',piano:'piano',other:'sonstiges',speech:'sprache-und-hintergrund'};function choose(f){selected=f;name.textContent=f?`${f.name} · ${(f.size/1048576).toFixed(1)} MiB`:'Noch keine Datei gewählt';start.disabled=!f}file.onchange=()=>choose(file.files[0]);drop.ondragover=e=>{e.preventDefault();drop.classList.add('drag')};drop.ondragleave=()=>drop.classList.remove('drag');drop.ondrop=e=>{e.preventDefault();drop.classList.remove('drag');choose(e.dataTransfer.files[0])};start.onclick=async()=>{const target=document.querySelector('input[name=target]:checked').value;start.disabled=true;bar.classList.add('run');status.textContent=target==='speech'?'MossFormer2 trennt Sprache und Hintergrund – das kann einige Minuten dauern …':'Modell löst die gewählte Spur heraus – das kann einige Minuten dauern …';let body=new FormData();body.append('file',selected);body.append('target',target);try{let r=await fetch('/v1/separate',{method:'POST',body});if(!r.ok)throw Error((await r.json()).detail||`HTTP ${r.status}`);let blob=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`athena-${names[target]}-und-rest.zip`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status.textContent='Fertig – ZIP mit der ausgewählten Spur und dem Rest wurde geladen.'}catch(e){status.textContent=`Fehler: ${e.message}`}finally{bar.classList.remove('run');start.disabled=false}};
|
||||||
|
</script></body></html>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from clearvoice import ClearVoice
|
||||||
|
|
||||||
|
|
||||||
|
MODEL = "MossFormer2_SE_48K"
|
||||||
|
SAMPLE_RATE = 48_000
|
||||||
|
|
||||||
|
|
||||||
|
def convert_input(source: Path, target: Path) -> None:
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||||
|
"-i", str(source), "-vn", "-ar", str(SAMPLE_RATE),
|
||||||
|
"-c:a", "pcm_f32le", str(target),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=1800,
|
||||||
|
)
|
||||||
|
if completed.returncode:
|
||||||
|
raise RuntimeError(completed.stderr[-4000:] or "ffmpeg input conversion failed")
|
||||||
|
|
||||||
|
|
||||||
|
def enhance(source: Path, speech_path: Path, noise_path: Path) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="clearvoice-") as temp_dir:
|
||||||
|
converted = Path(temp_dir) / "input-48k.wav"
|
||||||
|
convert_input(source, converted)
|
||||||
|
audio, sample_rate = sf.read(converted, dtype="float32", always_2d=True)
|
||||||
|
if sample_rate != SAMPLE_RATE:
|
||||||
|
raise RuntimeError(f"unexpected sample rate: {sample_rate}")
|
||||||
|
|
||||||
|
model = ClearVoice(task="speech_enhancement", model_names=[MODEL])
|
||||||
|
# Use ClearVoice's file-I/O path so recordings longer than its 20-second
|
||||||
|
# one-pass window are segmented correctly. Run each channel separately
|
||||||
|
# because the enhancement network itself is mono, then restore stereo.
|
||||||
|
channels = []
|
||||||
|
for channel_index in range(audio.shape[1]):
|
||||||
|
channel_path = Path(temp_dir) / f"channel-{channel_index}.wav"
|
||||||
|
sf.write(channel_path, audio[:, channel_index], SAMPLE_RATE, subtype="FLOAT")
|
||||||
|
result = np.asarray(model(str(channel_path), False), dtype=np.float32).squeeze()
|
||||||
|
if result.ndim != 1:
|
||||||
|
raise RuntimeError(f"unexpected ClearVoice output shape: {result.shape}")
|
||||||
|
channels.append(result)
|
||||||
|
enhanced = np.column_stack(channels)
|
||||||
|
|
||||||
|
length = min(len(audio), len(enhanced))
|
||||||
|
original = audio[:length]
|
||||||
|
speech = enhanced[:length]
|
||||||
|
noise = original - speech
|
||||||
|
|
||||||
|
# FLAC does not support floating-point samples. PCM_24 retains ample
|
||||||
|
# headroom and avoids the invalid FLOAT/FLAC combination in libsndfile.
|
||||||
|
sf.write(speech_path, np.clip(speech, -1.0, 1.0), SAMPLE_RATE, format="FLAC", subtype="PCM_24")
|
||||||
|
sf.write(noise_path, np.clip(noise, -1.0, 1.0), SAMPLE_RATE, format="FLAC", subtype="PCM_24")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("input", nargs="?", type=Path)
|
||||||
|
parser.add_argument("speech", nargs="?", type=Path)
|
||||||
|
parser.add_argument("noise", nargs="?", type=Path)
|
||||||
|
parser.add_argument("--download-only", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.download_only:
|
||||||
|
ClearVoice(task="speech_enhancement", model_names=[MODEL])
|
||||||
|
return
|
||||||
|
if not all((args.input, args.speech, args.noise)):
|
||||||
|
parser.error("input, speech and noise output paths are required")
|
||||||
|
enhance(args.input, args.speech, args.noise)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
d1ad2472a147caa1111bae5ec710331dc50692d62ebbdb3fbc54d421c4e209bc Dirk-Qwen3.8-27B-UD-Q4_K_XL.gguf
|
||||||
|
cbb841a9ee0636b2ec172f5bb8df2ea8dfeb01e90fe7c6126581d662a0b4e43e mmproj-F16.gguf
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Dirk Qwen3.8-27B experiment
|
||||||
|
|
||||||
|
Isolated A/B test environment for
|
||||||
|
`peculiar-ragdoll/Dirk-Qwen3.8-27B-GGUF`. It deliberately does not add a
|
||||||
|
production router profile and never stops or restarts production services.
|
||||||
|
|
||||||
|
## Candidate
|
||||||
|
|
||||||
|
- Main model: `Dirk-Qwen3.8-27B-UD-Q4_K_XL.gguf` (about 17.6 GB)
|
||||||
|
- Vision projector: `mmproj-F16.gguf`
|
||||||
|
- Pinned Hugging Face revision: `12362f2b3d7dc11044e99c9e7e99fb9f530528c0`
|
||||||
|
- Runtime: existing `mike-ai/llama.cpp:local`
|
||||||
|
- Test endpoint: `127.0.0.1:5004`
|
||||||
|
- Results: `/data/benchmarks/dirk-qwen38/`
|
||||||
|
|
||||||
|
The candidate is intended to reduce unnecessary reasoning and total token use;
|
||||||
|
it is not expected to improve raw decode speed. The production Qwen model is
|
||||||
|
therefore the mandatory A/B reference.
|
||||||
|
|
||||||
|
## Safety boundary
|
||||||
|
|
||||||
|
`run-case.sh` refuses to start while any production `mike-ai-llama-*` model
|
||||||
|
container is running. It does not stop production itself. The model server is
|
||||||
|
bound to loopback only and cannot be reached from the LAN.
|
||||||
|
|
||||||
|
## Measured matrix
|
||||||
|
|
||||||
|
Run the following only after the GPUs have explicitly been declared free:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./run-case.sh 80000 87,13 text
|
||||||
|
./run-case.sh 160000 80,20 text
|
||||||
|
./run-case.sh 192000 72,28 text
|
||||||
|
./run-case.sh 262144 70,30 text
|
||||||
|
```
|
||||||
|
|
||||||
|
The largest stable context is determined first. Vision is checked only after a
|
||||||
|
text winner exists:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./run-case.sh 160000 80,20 vision
|
||||||
|
```
|
||||||
|
|
||||||
|
For every case, record uncached prefill, cached prefill, decode throughput,
|
||||||
|
GPU memory, context recall, tool calling, code quality and total tokens needed
|
||||||
|
to finish the task. Do not promote Dirk unless it matches the base model on
|
||||||
|
technical correctness and improves real Hermes task completion.
|
||||||
|
|
||||||
|
Expected SHA-256 checksums are stored in `MODEL_ARTIFACTS.sha256`.
|
||||||
|
|
||||||
|
The completed A/B result is documented in
|
||||||
|
`docs/DIRK_QWEN38_AB_20260901.md`. The candidate did not replace the production
|
||||||
|
Pure Qwen profile.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small, dependency-free llama.cpp performance and context probe."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def post(base: str, path: str, payload: dict, timeout: int = 1800) -> tuple[dict, float]:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
base + path,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
started = time.monotonic()
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
result = json.load(response)
|
||||||
|
return result, time.monotonic() - started
|
||||||
|
|
||||||
|
|
||||||
|
def make_text(lines: int) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
f"Record {n:06d}: cobalt lantern maple orbit quartz river silver tango." for n in range(lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_tokens(base: str, text: str) -> int:
|
||||||
|
result, _ = post(base, "/tokenize", {"content": text, "add_special": False})
|
||||||
|
return len(result.get("tokens", []))
|
||||||
|
|
||||||
|
|
||||||
|
def sized_text(base: str, target: int) -> tuple[str, int]:
|
||||||
|
# One probe establishes the tokenizer-specific tokens per synthetic line.
|
||||||
|
sample = make_text(100)
|
||||||
|
per_line = max(1.0, count_tokens(base, sample) / 100)
|
||||||
|
lines = max(1, int(target / per_line))
|
||||||
|
text = make_text(lines)
|
||||||
|
actual = count_tokens(base, text)
|
||||||
|
if actual < target * 0.95:
|
||||||
|
lines = int(lines * target / max(1, actual))
|
||||||
|
text = make_text(lines)
|
||||||
|
actual = count_tokens(base, text)
|
||||||
|
return text, actual
|
||||||
|
|
||||||
|
|
||||||
|
def chat(base: str, prompt: str, max_tokens: int, temperature: float = 0.2) -> dict:
|
||||||
|
result, wall = post(base, "/v1/chat/completions", {
|
||||||
|
"model": "benchmark",
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"reasoning_effort": "none",
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
})
|
||||||
|
message = (result.get("choices") or [{}])[0].get("message") or {}
|
||||||
|
return {
|
||||||
|
"wall_seconds": round(wall, 3),
|
||||||
|
"timings": result.get("timings", {}),
|
||||||
|
"usage": result.get("usage", {}),
|
||||||
|
"content": message.get("content", ""),
|
||||||
|
"reasoning_content": message.get("reasoning_content", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("label")
|
||||||
|
parser.add_argument("context", type=int)
|
||||||
|
parser.add_argument("--base", default="http://127.0.0.1:5004")
|
||||||
|
parser.add_argument("--output", default="/data/benchmarks/dirk-qwen38")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result: dict = {"label": args.label, "context": args.context, "started": time.time()}
|
||||||
|
short, short_n = sized_text(args.base, min(16000, max(4000, args.context // 10)))
|
||||||
|
prompt = short + "\nReply with exactly: PREFILL-OK"
|
||||||
|
result["prompt_tokens_synthetic"] = short_n
|
||||||
|
result["uncached"] = chat(args.base, prompt, 32)
|
||||||
|
result["cached"] = chat(args.base, prompt, 32)
|
||||||
|
|
||||||
|
output_prompt = (
|
||||||
|
"Return exactly 256 comma-separated integers beginning at 1 and ending at 256. "
|
||||||
|
"Do not explain and do not omit any integer."
|
||||||
|
)
|
||||||
|
result["decode"] = chat(args.base, output_prompt, 768)
|
||||||
|
|
||||||
|
recall_target = int(args.context * 0.70)
|
||||||
|
long_text, long_n = sized_text(args.base, recall_target)
|
||||||
|
marks = [
|
||||||
|
(len(long_text) // 8, "NEEDLE_ALPHA=RAVEN-417"),
|
||||||
|
(len(long_text) // 2, "NEEDLE_BETA=CEDAR-928"),
|
||||||
|
(len(long_text) * 7 // 8, "NEEDLE_GAMMA=ORBIT-563"),
|
||||||
|
]
|
||||||
|
for position, needle in reversed(marks):
|
||||||
|
long_text = long_text[:position] + "\n" + needle + "\n" + long_text[position:]
|
||||||
|
recall_prompt = long_text + (
|
||||||
|
"\nReturn only a JSON object with keys alpha, beta, gamma and their exact values "
|
||||||
|
"from the three NEEDLE lines."
|
||||||
|
)
|
||||||
|
recall = chat(args.base, recall_prompt, 256)
|
||||||
|
recall["synthetic_tokens"] = long_n
|
||||||
|
content = recall.get("content", "")
|
||||||
|
recall["needles_found"] = {
|
||||||
|
"alpha": "RAVEN-417" in content,
|
||||||
|
"beta": "CEDAR-928" in content,
|
||||||
|
"gamma": "ORBIT-563" in content,
|
||||||
|
}
|
||||||
|
result["recall"] = recall
|
||||||
|
result["finished"] = time.time()
|
||||||
|
|
||||||
|
output = pathlib.Path(args.output)
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = output / f"{args.label}.json"
|
||||||
|
target.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
MODEL_DIR=${MODEL_DIR:-/data/models/qwen3.8-27b-dirk}
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REVISION=12362f2b3d7dc11044e99c9e7e99fb9f530528c0
|
||||||
|
BASE_URL="https://huggingface.co/peculiar-ragdoll/Dirk-Qwen3.8-27B-GGUF/resolve/$REVISION"
|
||||||
|
MODEL=Dirk-Qwen3.8-27B-UD-Q4_K_XL.gguf
|
||||||
|
PROJECTOR=mmproj-F16.gguf
|
||||||
|
|
||||||
|
install -d -m 0755 "$MODEL_DIR"
|
||||||
|
|
||||||
|
download() {
|
||||||
|
local name=$1
|
||||||
|
local target="$MODEL_DIR/$name"
|
||||||
|
curl --fail --location --retry 8 --retry-delay 5 --continue-at - \
|
||||||
|
--output "$target.part" "$BASE_URL/$name"
|
||||||
|
mv -f "$target.part" "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -s "$MODEL_DIR/$MODEL" ]] || download "$MODEL"
|
||||||
|
[[ -s "$MODEL_DIR/$PROJECTOR" ]] || download "$PROJECTOR"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$MODEL_DIR"
|
||||||
|
sha256sum -c "$SCRIPT_DIR/MODEL_ARTIFACTS.sha256"
|
||||||
|
cp "$SCRIPT_DIR/MODEL_ARTIFACTS.sha256" SHA256SUMS
|
||||||
|
)
|
||||||
|
|
||||||
|
printf 'Prepared model files in %s\n' "$MODEL_DIR"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run the fixed Qwen acceptance prompts and a native tool-call probe."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def post(base: str, payload: dict, timeout: int = 1800) -> tuple[dict, float]:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
base + "/v1/chat/completions",
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
started = time.monotonic()
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return json.load(response), time.monotonic() - started
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("label")
|
||||||
|
parser.add_argument("--base", default="http://127.0.0.1:5004")
|
||||||
|
parser.add_argument("--tasks", default="/opt/mike-ai/stack/dev/QWEN38-FINAL-ACCEPTANCE-v1.json")
|
||||||
|
parser.add_argument("--output", default="/data/benchmarks/dirk-qwen38")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tasks = json.loads(pathlib.Path(args.tasks).read_text())
|
||||||
|
results = []
|
||||||
|
for task in tasks:
|
||||||
|
response, wall = post(args.base, {
|
||||||
|
"model": "benchmark",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": task["max_tokens"],
|
||||||
|
"reasoning_effort": "medium",
|
||||||
|
"messages": [{"role": "user", "content": task["prompt"]}],
|
||||||
|
})
|
||||||
|
message = (response.get("choices") or [{}])[0].get("message") or {}
|
||||||
|
results.append({
|
||||||
|
"id": task["id"],
|
||||||
|
"finish_reason": (response.get("choices") or [{}])[0].get("finish_reason"),
|
||||||
|
"wall_seconds": round(wall, 3),
|
||||||
|
"content": message.get("content", ""),
|
||||||
|
"reasoning_content": message.get("reasoning_content", ""),
|
||||||
|
"usage": response.get("usage", {}),
|
||||||
|
"timings": response.get("timings", {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
tool_response, tool_wall = post(args.base, {
|
||||||
|
"model": "benchmark",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": 500,
|
||||||
|
"reasoning_effort": "none",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Read the current status of server alpha. Use the provided tool exactly once and do not invent its result.",
|
||||||
|
}],
|
||||||
|
"tools": [{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "read_server_status",
|
||||||
|
"description": "Read-only server status lookup",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"server": {"type": "string"}},
|
||||||
|
"required": ["server"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
"tool_choice": "auto",
|
||||||
|
})
|
||||||
|
tool_message = (tool_response.get("choices") or [{}])[0].get("message") or {}
|
||||||
|
report = {
|
||||||
|
"label": args.label,
|
||||||
|
"results": results,
|
||||||
|
"tool_probe": {
|
||||||
|
"wall_seconds": round(tool_wall, 3),
|
||||||
|
"message": tool_message,
|
||||||
|
"timings": tool_response.get("timings", {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
output = pathlib.Path(args.output)
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = output / f"quality-{args.label}.json"
|
||||||
|
target.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
print(target)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
CONTEXT=${1:-}
|
||||||
|
SPLIT=${2:-}
|
||||||
|
MODE=${3:-text}
|
||||||
|
|
||||||
|
if [[ -z $CONTEXT || -z $SPLIT || ! $CONTEXT =~ ^[0-9]+$ || ! $SPLIT =~ ^[0-9]+,[0-9]+$ ]]; then
|
||||||
|
echo "Usage: $0 CONTEXT TENSOR_SPLIT {text|vision}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ $MODE != text && $MODE != vision ]]; then
|
||||||
|
echo "Mode must be text or vision" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
MODEL_DIR=${MODEL_DIR:-/data/models/qwen3.8-27b-dirk}
|
||||||
|
MODEL=${MODEL_PATH:-/models/qwen3.8-27b-dirk/Dirk-Qwen3.8-27B-UD-Q4_K_XL.gguf}
|
||||||
|
HOST_MODEL_FILE=${HOST_MODEL_FILE:-$MODEL_DIR/Dirk-Qwen3.8-27B-UD-Q4_K_XL.gguf}
|
||||||
|
MODEL_ALIAS=${MODEL_ALIAS:-qwen-dirk-test}
|
||||||
|
PROJECTOR=/models/qwen3.8-27b-dirk/mmproj-F16.gguf
|
||||||
|
IMAGE=${LLAMA_IMAGE:-mike-ai/llama.cpp:local}
|
||||||
|
NAME=mike-ai-llama-dirk-test
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/dirk-qwen38}
|
||||||
|
|
||||||
|
[[ -s "$HOST_MODEL_FILE" ]] || {
|
||||||
|
echo "Model file is missing: $HOST_MODEL_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if [[ $MODE == vision && ! -s "$MODEL_DIR/mmproj-F16.gguf" ]]; then
|
||||||
|
echo "Vision projector is missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t blockers < <(
|
||||||
|
docker ps --format '{{.Names}}' |
|
||||||
|
grep -E '^mike-ai-llama-' |
|
||||||
|
grep -vE '^mike-ai-llama-dashboard$|^mike-ai-llama-dirk-test$' || true
|
||||||
|
)
|
||||||
|
if ((${#blockers[@]})); then
|
||||||
|
printf 'Refusing to start: production model container(s) still running: %s\n' "${blockers[*]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||||
|
install -d -m 0755 "$RESULT_DIR"
|
||||||
|
|
||||||
|
args=(
|
||||||
|
--model "$MODEL"
|
||||||
|
--alias "$MODEL_ALIAS"
|
||||||
|
--ctx-size "$CONTEXT"
|
||||||
|
--flash-attn on
|
||||||
|
--cache-type-k q4_0
|
||||||
|
--cache-type-v q4_0
|
||||||
|
--cache-prompt
|
||||||
|
--cache-ram 24576
|
||||||
|
--threads 6
|
||||||
|
--threads-batch 6
|
||||||
|
--batch-size 2048
|
||||||
|
--ubatch-size 128
|
||||||
|
--parallel 1
|
||||||
|
--kv-unified
|
||||||
|
--jinja
|
||||||
|
--reasoning auto
|
||||||
|
--reasoning-budget 8192
|
||||||
|
--reasoning-preserve
|
||||||
|
--host 127.0.0.1
|
||||||
|
--port 5004
|
||||||
|
--metrics
|
||||||
|
--fit off
|
||||||
|
--n-gpu-layers all
|
||||||
|
--no-mmap
|
||||||
|
--temperature 1.0
|
||||||
|
--top-p 0.95
|
||||||
|
--top-k 20
|
||||||
|
--device CUDA0,CUDA1
|
||||||
|
--main-gpu 0
|
||||||
|
--split-mode layer
|
||||||
|
--tensor-split "$SPLIT"
|
||||||
|
--spec-type draft-mtp
|
||||||
|
--spec-draft-n-max 3
|
||||||
|
--spec-draft-type-k f16
|
||||||
|
--spec-draft-type-v f16
|
||||||
|
--spec-draft-p-min 0.05
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ $MODE == vision ]]; then
|
||||||
|
args+=(--mmproj "$PROJECTOR" --no-mmproj-offload)
|
||||||
|
fi
|
||||||
|
|
||||||
|
label="ctx${CONTEXT}-split${SPLIT//,/-}-${MODE}"
|
||||||
|
docker run -d \
|
||||||
|
--name "$NAME" \
|
||||||
|
--gpus all \
|
||||||
|
--network host \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=256m \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--pids-limit 1024 \
|
||||||
|
--log-opt max-size=20m \
|
||||||
|
--log-opt max-file=2 \
|
||||||
|
-v /data/models:/models:ro \
|
||||||
|
-v "$RESULT_DIR":/results \
|
||||||
|
--label mike-ai.experiment=dirk-qwen38 \
|
||||||
|
--label mike-ai.case="$label" \
|
||||||
|
"$IMAGE" "${args[@]}"
|
||||||
|
|
||||||
|
printf 'Started isolated case %s on http://127.0.0.1:5004\n' "$label"
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
docker rm -f mike-ai-llama-dirk-test >/dev/null 2>&1 || true
|
||||||
|
echo "Dirk test container stopped. Production was not changed."
|
||||||
|
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
deadline=$((SECONDS + ${READY_TIMEOUT:-900}))
|
||||||
|
until curl -fsS --max-time 3 http://127.0.0.1:5004/health >/dev/null; do
|
||||||
|
state=$(docker inspect -f '{{.State.Running}}' mike-ai-llama-dirk-test 2>/dev/null || true)
|
||||||
|
if [[ $state != true ]]; then
|
||||||
|
docker logs --tail 100 mike-ai-llama-dirk-test >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ((SECONDS >= deadline)); then
|
||||||
|
docker logs --tail 100 mike-ai-llama-dirk-test >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
curl -fsS http://127.0.0.1:5004/props
|
||||||
|
printf '\nDirk test server is ready.\n'
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# GSQ-RCO IQ3_S profile A/B test
|
||||||
|
|
||||||
|
`run-case.sh` starts one isolated llama.cpp test container for either the
|
||||||
|
current Q4 reference or GSQ-RCO IQ3_S. It refuses to start while a production
|
||||||
|
LLM container is running. Context, GPU split, vision projector, MTP depth and
|
||||||
|
batch sizes are explicit command-line arguments so every candidate can use the
|
||||||
|
same settings as its corresponding production profile.
|
||||||
|
|
||||||
|
The 2026-09-08 run used the existing dependency-free benchmark programs:
|
||||||
|
|
||||||
|
- `experiments/dirk-qwen38/bench-case.py`
|
||||||
|
- `experiments/dirk-qwen38/quality-ab.py`
|
||||||
|
- `dev/QWEN38-FINAL-ACCEPTANCE-v1.json`
|
||||||
|
|
||||||
|
Results are retained on Athena in
|
||||||
|
`/data/model-benchmarks/gsq-rco-iq3s-ab-v2-20260908/`. The conclusions and
|
||||||
|
aggregate measurements are documented in
|
||||||
|
`docs/GSQ_RCO_BETA1_20260904.md`.
|
||||||
Executable
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
MODEL=${1:-}
|
||||||
|
CONTEXT=${2:-}
|
||||||
|
SPLIT=${3:-}
|
||||||
|
VISION=${4:-no}
|
||||||
|
MTP=${5:-3}
|
||||||
|
BATCH=${6:-2048}
|
||||||
|
UBATCH=${7:-128}
|
||||||
|
|
||||||
|
case "$MODEL" in
|
||||||
|
q4-pure)
|
||||||
|
MODEL_FILE=/models/qwen3.8-27b-iq4-xs-pure/qwen3.8-27b-IQ4_XS-pure.gguf
|
||||||
|
;;
|
||||||
|
q4-mix)
|
||||||
|
MODEL_FILE=/models/qwen3.8-27b-iq4-mix/Qwen3.8-27B-IQ4-MIX.gguf
|
||||||
|
;;
|
||||||
|
iq3s)
|
||||||
|
MODEL_FILE=/models/qwen3.8-27b-gsq-rco-iq3s/Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {q4-pure|q4-mix|iq3s} CONTEXT {none|PERCENT,PERCENT} {yes|no}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ $CONTEXT =~ ^[0-9]+$ ]] || { echo "Invalid context" >&2; exit 2; }
|
||||||
|
[[ $SPLIT == none || $SPLIT =~ ^[0-9]+,[0-9]+$ ]] || { echo "Invalid split" >&2; exit 2; }
|
||||||
|
[[ $VISION == yes || $VISION == no ]] || { echo "Invalid vision setting" >&2; exit 2; }
|
||||||
|
[[ $MTP =~ ^[0-9]+$ ]] || { echo "Invalid MTP setting" >&2; exit 2; }
|
||||||
|
[[ $BATCH =~ ^[0-9]+$ ]] || { echo "Invalid batch setting" >&2; exit 2; }
|
||||||
|
[[ $UBATCH =~ ^[0-9]+$ ]] || { echo "Invalid ubatch setting" >&2; exit 2; }
|
||||||
|
|
||||||
|
NAME=mike-ai-llama-gsq-v2
|
||||||
|
IMAGE=${LLAMA_IMAGE:-mike-ai/llama.cpp:local}
|
||||||
|
|
||||||
|
if docker ps --format '{{.Names}}' | grep -qx "$NAME"; then
|
||||||
|
echo "A/B container already running" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t blockers < <(
|
||||||
|
docker ps --format '{{.Names}}' |
|
||||||
|
grep -E '^mike-ai-llama-' |
|
||||||
|
grep -vE '^mike-ai-llama-dashboard$' || true
|
||||||
|
)
|
||||||
|
if ((${#blockers[@]})); then
|
||||||
|
printf 'Production model still running: %s\n' "${blockers[*]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
args=(
|
||||||
|
--model "$MODEL_FILE"
|
||||||
|
--alias benchmark
|
||||||
|
--ctx-size "$CONTEXT"
|
||||||
|
--flash-attn on
|
||||||
|
--cache-type-k q4_0
|
||||||
|
--cache-type-v q4_0
|
||||||
|
--cache-prompt
|
||||||
|
--cache-reuse 256
|
||||||
|
--cache-ram 8192
|
||||||
|
--threads 6
|
||||||
|
--threads-batch 6
|
||||||
|
--batch-size "$BATCH"
|
||||||
|
--ubatch-size "$UBATCH"
|
||||||
|
--parallel 1
|
||||||
|
--kv-unified
|
||||||
|
--jinja
|
||||||
|
--reasoning auto
|
||||||
|
--reasoning-preserve
|
||||||
|
--host 127.0.0.1
|
||||||
|
--port 5005
|
||||||
|
--metrics
|
||||||
|
--fit off
|
||||||
|
--n-gpu-layers all
|
||||||
|
--no-mmap
|
||||||
|
--no-ui
|
||||||
|
--temperature 0.2
|
||||||
|
--top-p 0.8
|
||||||
|
--top-k 20
|
||||||
|
--spec-type draft-mtp
|
||||||
|
--spec-draft-n-max "$MTP"
|
||||||
|
--spec-draft-type-k f16
|
||||||
|
--spec-draft-type-v f16
|
||||||
|
)
|
||||||
|
|
||||||
|
env_args=()
|
||||||
|
if [[ $VISION == yes ]]; then
|
||||||
|
env_args=(-e MTMD_BACKEND_DEVICE=CUDA1)
|
||||||
|
args+=(--mmproj /models/qwen/mmproj-BF16.gguf --mmproj-device CUDA1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $SPLIT == none ]]; then
|
||||||
|
args+=(--device CUDA0 --main-gpu 0 --split-mode none)
|
||||||
|
else
|
||||||
|
args+=(--device CUDA0,CUDA1 --main-gpu 0 --split-mode layer --tensor-split "$SPLIT")
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name "$NAME" \
|
||||||
|
--gpus all \
|
||||||
|
--network host \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=256m \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--pids-limit 1024 \
|
||||||
|
--log-opt max-size=20m \
|
||||||
|
--log-opt max-file=2 \
|
||||||
|
-v /data/models:/models:ro \
|
||||||
|
"${env_args[@]}" \
|
||||||
|
--label mike-ai.experiment=gsq-rco-iq3s-ab-v2 \
|
||||||
|
"$IMAGE" "${args[@]}"
|
||||||
|
|
||||||
|
deadline=$((SECONDS + 900))
|
||||||
|
until curl -fsS --max-time 3 http://127.0.0.1:5005/health >/dev/null; do
|
||||||
|
if [[ $(docker inspect -f '{{.State.Running}}' "$NAME" 2>/dev/null || true) != true ]]; then
|
||||||
|
docker logs --tail 100 "$NAME" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ((SECONDS >= deadline)); then
|
||||||
|
docker logs --tail 100 "$NAME" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -fsS http://127.0.0.1:5005/props
|
||||||
|
printf '\nReady: %s, context %s, split %s, vision %s\n' "$MODEL" "$CONTEXT" "$SPLIT" "$VISION"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
FROM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu24.04
|
||||||
|
|
||||||
|
ARG OMNIVOICE_VERSION=0.2.1
|
||||||
|
ARG OMNIVOICE_TRITON_VERSION=0.1.0
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl ffmpeg libsndfile1 python3 python3-pip python3-venv \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python3 -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:${PATH}"
|
||||||
|
|
||||||
|
RUN python -m pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& python -m pip install --no-cache-dir \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu128 \
|
||||||
|
"torch==2.8.0" "torchaudio==2.8.0" \
|
||||||
|
&& python -m pip install --no-cache-dir \
|
||||||
|
"omnivoice==${OMNIVOICE_VERSION}" \
|
||||||
|
"omnivoice-triton==${OMNIVOICE_TRITON_VERSION}" \
|
||||||
|
"num2words>=0.5.14"
|
||||||
|
|
||||||
|
EXPOSE 8008
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=5s --timeout=3s --start-period=600s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8008/ >/dev/null || exit 1
|
||||||
|
|
||||||
|
CMD ["omnivoice-demo", "--ip", "0.0.0.0", "--port", "8008"]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# OmniVoice cloning gate on Athena
|
||||||
|
|
||||||
|
This is the isolated quality gate for `k2-fsa/OmniVoice` 0.2.1. It exposes
|
||||||
|
the upstream Gradio demo through Athena's existing private Voice Studio route.
|
||||||
|
The image also contains `omnivoice-triton` 0.1.0 for a later measured
|
||||||
|
base-versus-optimized benchmark; the upstream UI deliberately starts in the
|
||||||
|
unmodified reference mode so kernel changes cannot contaminate the first
|
||||||
|
listening test.
|
||||||
|
|
||||||
|
- Model weights: CC-BY-NC
|
||||||
|
- Code: Apache-2.0
|
||||||
|
- Private URL: `http://192.168.1.212:8008`
|
||||||
|
- Persistent cache: `/data/voice/omnivoice/huggingface`
|
||||||
|
- GPU allocator workaround: `expandable_segments:True`
|
||||||
|
|
||||||
|
Use a clean 3–10 second reference and provide its exact transcript. German
|
||||||
|
target text should be written out normally; avoid raw abbreviations and digits
|
||||||
|
in the first quality test.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
services:
|
||||||
|
voice-studio:
|
||||||
|
build: .
|
||||||
|
image: mike-ai/omnivoice-studio:0.2.1
|
||||||
|
container_name: mike-ai-voice-studio
|
||||||
|
restart: "no"
|
||||||
|
labels:
|
||||||
|
# Kept compatible with the current controller during the A/B gate.
|
||||||
|
com.mike-ai.voice-worker: vevo2
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
HF_HOME: /models/huggingface
|
||||||
|
PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8008:8008"
|
||||||
|
volumes:
|
||||||
|
- /data/voice/omnivoice/huggingface:/models/huggingface
|
||||||
|
- /data/voice/omnivoice/output:/output
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8008/ >/dev/null"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 600s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
aliases: [voice-studio]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
name: mike-ai_frontend
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Qwen3.8 September 2026 A/B preparation
|
||||||
|
|
||||||
|
This directory prepares an isolated comparison of two Qwen3.8-27B IQ4_XS
|
||||||
|
artifacts without adding router profiles or changing the running Athena stack.
|
||||||
|
|
||||||
|
## Candidates
|
||||||
|
|
||||||
|
| ID | Artifact | Purpose | Pinned revision | Size |
|
||||||
|
| --- | --- | --- | --- | ---: |
|
||||||
|
| `qwopus` | `Jackrong/Qwopus3.8-27B-Flash-GGUF` / `Qwopus3.8-27B-Flash-MTP-IQ4_XS.gguf` | Efficiency fine-tune | `e146d61e88782677805b3b68ad3adf8674dde80d` | 15,420,445,792 B |
|
||||||
|
| `bartowski` | `bartowski/Qwen3.8-27B-GGUF` / `Qwen3.8-27B-IQ4_XS.gguf` | Standard Qwen, alternative IQ4_XS quant | `f0eec4a4bb4975114a030d048952d83c0a53c034` | 15,567,824,480 B |
|
||||||
|
|
||||||
|
The production reference remains
|
||||||
|
`jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF`. The candidates intentionally use the
|
||||||
|
same IQ4_XS quantization class so that the first comparison does not mix a
|
||||||
|
fine-tune difference with a quantization-class difference.
|
||||||
|
|
||||||
|
## Safety boundary
|
||||||
|
|
||||||
|
- Nothing in this directory is called by installation, Compose or the router.
|
||||||
|
- No production profile is added.
|
||||||
|
- Downloads happen only after explicitly running `download-candidate.sh`.
|
||||||
|
- `run-case.sh` refuses to start while a production `mike-ai-llama-*` model is
|
||||||
|
running. It never stops production itself.
|
||||||
|
- The test server binds to `127.0.0.1:5005` and is not exposed to the LAN.
|
||||||
|
- Cleanup is a dry run unless an explicit deletion flag is supplied. It only
|
||||||
|
addresses the exact test container, result directory and two pinned files.
|
||||||
|
|
||||||
|
## Later test sequence
|
||||||
|
|
||||||
|
Run these commands on Athena only after the active coding task has finished and
|
||||||
|
the GPUs have deliberately been released:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/mike-ai/stack/experiments/qwen38-20260907-ab
|
||||||
|
./inventory.sh
|
||||||
|
./download-candidate.sh qwopus
|
||||||
|
./download-candidate.sh bartowski
|
||||||
|
|
||||||
|
./run-case.sh qwopus 160000 85,15
|
||||||
|
./wait-ready.sh
|
||||||
|
./run-benchmark.sh qwopus-160k
|
||||||
|
./stop-case.sh
|
||||||
|
|
||||||
|
./run-case.sh bartowski 160000 85,15
|
||||||
|
./wait-ready.sh
|
||||||
|
./run-benchmark.sh bartowski-160k
|
||||||
|
./stop-case.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Only after both candidates pass the 160K quality and tool-call tests should
|
||||||
|
192K and 262144 be attempted. Promotion into the router is a separate decision
|
||||||
|
and is deliberately not implemented here.
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Preview everything owned by this experiment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cleanup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove only the test container and benchmark results:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cleanup.sh --results
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove only the two downloaded candidate files and their now-empty directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cleanup.sh --models
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove both:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cleanup.sh --all
|
||||||
|
```
|
||||||
|
|
||||||
|
The script never touches the production Pure, Mix, Beta 1 or uncensored model
|
||||||
|
directories.
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
candidate_config() {
|
||||||
|
case "${1:-}" in
|
||||||
|
qwopus)
|
||||||
|
CANDIDATE_ID=qwopus
|
||||||
|
REPOSITORY=Jackrong/Qwopus3.8-27B-Flash-GGUF
|
||||||
|
REVISION=e146d61e88782677805b3b68ad3adf8674dde80d
|
||||||
|
MODEL_FILE=Qwopus3.8-27B-Flash-MTP-IQ4_XS.gguf
|
||||||
|
MODEL_SHA256=88848920fd069ecfe509afd60d4b1f2192327f1edde5e10491a48f7f80c16fb8
|
||||||
|
MODEL_SIZE=15420445792
|
||||||
|
MODEL_ALIAS=qwen-qwopus-flash-ab
|
||||||
|
;;
|
||||||
|
bartowski)
|
||||||
|
CANDIDATE_ID=bartowski
|
||||||
|
REPOSITORY=bartowski/Qwen3.8-27B-GGUF
|
||||||
|
REVISION=f0eec4a4bb4975114a030d048952d83c0a53c034
|
||||||
|
MODEL_FILE=Qwen3.8-27B-IQ4_XS.gguf
|
||||||
|
MODEL_SHA256=c2ae2b018f967370087c196c86d6811b2340ec19138a3752252ade5fbd1f4786
|
||||||
|
MODEL_SIZE=15567824480
|
||||||
|
MODEL_ALIAS=qwen-bartowski-iq4-xs-ab
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Candidate must be qwopus or bartowski" >&2
|
||||||
|
return 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
MODEL_ROOT=${MODEL_ROOT:-/data/models/experiments/qwen38-ab-20260907}
|
||||||
|
MODEL_PATH="$MODEL_ROOT/$CANDIDATE_ID/$MODEL_FILE"
|
||||||
|
MODEL_URL="https://huggingface.co/$REPOSITORY/resolve/$REVISION/$MODEL_FILE"
|
||||||
|
}
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
MODE=${1:-preview}
|
||||||
|
MODEL_ROOT=${MODEL_ROOT:-/data/models/experiments/qwen38-ab-20260907}
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/qwen38-ab-20260907}
|
||||||
|
NAME=mike-ai-llama-qwen38-ab
|
||||||
|
|
||||||
|
QWOPUS="$MODEL_ROOT/qwopus/Qwopus3.8-27B-Flash-MTP-IQ4_XS.gguf"
|
||||||
|
BARTOWSKI="$MODEL_ROOT/bartowski/Qwen3.8-27B-IQ4_XS.gguf"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
preview)
|
||||||
|
echo "Dry run only. Exact owned targets:"
|
||||||
|
printf ' container: %s\n results: %s\n model: %s\n model: %s\n' \
|
||||||
|
"$NAME" "$RESULT_DIR" "$QWOPUS" "$BARTOWSKI"
|
||||||
|
echo "Use --results, --models or --all to delete these exact targets."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--results|--models|--all) ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [--results|--models|--all]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if [[ $MODE == --results || $MODE == --all ]]; then
|
||||||
|
if [[ -d $RESULT_DIR ]]; then
|
||||||
|
find "$RESULT_DIR" -maxdepth 1 -type f -name '*.json' -delete
|
||||||
|
rmdir "$RESULT_DIR" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $MODE == --models || $MODE == --all ]]; then
|
||||||
|
rm -f -- "$QWOPUS" "$QWOPUS.part" "$BARTOWSKI" "$BARTOWSKI.part"
|
||||||
|
rmdir "$MODEL_ROOT/qwopus" "$MODEL_ROOT/bartowski" 2>/dev/null || true
|
||||||
|
rmdir "$MODEL_ROOT" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleanup complete for mode $MODE. No production model path was addressed."
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=candidates.sh
|
||||||
|
source "$SCRIPT_DIR/candidates.sh"
|
||||||
|
candidate_config "${1:-}"
|
||||||
|
|
||||||
|
target_dir="$MODEL_ROOT/$CANDIDATE_ID"
|
||||||
|
target="$target_dir/$MODEL_FILE"
|
||||||
|
partial="$target.part"
|
||||||
|
|
||||||
|
install -d -m 0755 "$target_dir"
|
||||||
|
|
||||||
|
if [[ -s $target ]]; then
|
||||||
|
printf '%s %s\n' "$MODEL_SHA256" "$target" | sha256sum -c -
|
||||||
|
echo "Already prepared: $target"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading pinned $CANDIDATE_ID artifact ($MODEL_SIZE bytes)"
|
||||||
|
curl --fail --location --retry 8 --retry-delay 5 --continue-at - \
|
||||||
|
--output "$partial" "$MODEL_URL"
|
||||||
|
|
||||||
|
actual_size=$(stat -c %s "$partial")
|
||||||
|
if [[ $actual_size != "$MODEL_SIZE" ]]; then
|
||||||
|
echo "Size mismatch: expected $MODEL_SIZE, got $actual_size; keeping $partial for inspection" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s %s\n' "$MODEL_SHA256" "$partial" | sha256sum -c -
|
||||||
|
mv -f "$partial" "$target"
|
||||||
|
echo "Prepared and verified: $target"
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
MODEL_ROOT=${MODEL_ROOT:-/data/models/experiments/qwen38-ab-20260907}
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/qwen38-ab-20260907}
|
||||||
|
|
||||||
|
echo "== Containers owned by this experiment =="
|
||||||
|
docker ps -a --filter label=mike-ai.experiment=qwen38-ab-20260907 \
|
||||||
|
--format 'table {{.Names}}\t{{.Status}}\t{{.Label "mike-ai.candidate"}}' || true
|
||||||
|
|
||||||
|
echo "== Candidate model files and partial downloads =="
|
||||||
|
if [[ -d $MODEL_ROOT ]]; then
|
||||||
|
find "$MODEL_ROOT" -maxdepth 2 -type f -exec ls -lh {} +
|
||||||
|
else
|
||||||
|
echo "Not present: $MODEL_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Benchmark results =="
|
||||||
|
if [[ -d $RESULT_DIR ]]; then
|
||||||
|
find "$RESULT_DIR" -maxdepth 1 -type f -exec ls -lh {} +
|
||||||
|
else
|
||||||
|
echo "Not present: $RESULT_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Known older experimental model directories (read-only report) =="
|
||||||
|
for path in \
|
||||||
|
/data/models/qwen3.8-27b-dirk \
|
||||||
|
/data/models/qwen3.8-27b-gsq-rco-test \
|
||||||
|
/data/models/qwen3.8-27b-iq4-mix \
|
||||||
|
/data/models/qwen3.8-27b-iq4-xs-pure; do
|
||||||
|
[[ -d $path ]] && du -sh "$path"
|
||||||
|
done
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
LABEL=${1:-}
|
||||||
|
CONTEXT=${2:-160000}
|
||||||
|
[[ -n $LABEL && $LABEL =~ ^[a-zA-Z0-9._-]+$ ]] || {
|
||||||
|
echo "Usage: $0 SAFE_LABEL [CONTEXT]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[[ $CONTEXT =~ ^[0-9]+$ ]] || {
|
||||||
|
echo "CONTEXT must be an integer" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REFERENCE_DIR="$SCRIPT_DIR/../dirk-qwen38"
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/qwen38-ab-20260907}
|
||||||
|
TASKS=${TASKS:-/opt/mike-ai/stack/dev/QWEN38-FINAL-ACCEPTANCE-v1.json}
|
||||||
|
|
||||||
|
curl -fsS --max-time 3 http://127.0.0.1:5005/health >/dev/null
|
||||||
|
python3 "$REFERENCE_DIR/bench-case.py" "$LABEL" "$CONTEXT" \
|
||||||
|
--base http://127.0.0.1:5005 --output "$RESULT_DIR"
|
||||||
|
python3 "$REFERENCE_DIR/quality-ab.py" "$LABEL" \
|
||||||
|
--base http://127.0.0.1:5005 --tasks "$TASKS" --output "$RESULT_DIR"
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
CANDIDATE=${1:-}
|
||||||
|
CONTEXT=${2:-}
|
||||||
|
SPLIT=${3:-}
|
||||||
|
|
||||||
|
if [[ -z $CANDIDATE || -z $CONTEXT || -z $SPLIT || ! $CONTEXT =~ ^[0-9]+$ || ! $SPLIT =~ ^[0-9]+,[0-9]+$ ]]; then
|
||||||
|
echo "Usage: $0 {qwopus|bartowski} CONTEXT TENSOR_SPLIT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=candidates.sh
|
||||||
|
source "$SCRIPT_DIR/candidates.sh"
|
||||||
|
candidate_config "$CANDIDATE"
|
||||||
|
|
||||||
|
HOST_MODEL_FILE=$MODEL_PATH
|
||||||
|
CONTAINER_MODEL="/models/$CANDIDATE_ID/$MODEL_FILE"
|
||||||
|
IMAGE=${LLAMA_IMAGE:-mike-ai/llama.cpp:local}
|
||||||
|
NAME=mike-ai-llama-qwen38-ab
|
||||||
|
RESULT_DIR=${RESULT_DIR:-/data/benchmarks/qwen38-ab-20260907}
|
||||||
|
|
||||||
|
[[ -s $HOST_MODEL_FILE ]] || {
|
||||||
|
echo "Candidate is not downloaded: $HOST_MODEL_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
printf '%s %s\n' "$MODEL_SHA256" "$HOST_MODEL_FILE" | sha256sum -c -
|
||||||
|
|
||||||
|
mapfile -t blockers < <(
|
||||||
|
docker ps --format '{{.Names}}' |
|
||||||
|
grep -E '^mike-ai-llama-' |
|
||||||
|
grep -vE '^mike-ai-llama-dashboard$|^mike-ai-llama-qwen38-ab$' || true
|
||||||
|
)
|
||||||
|
if ((${#blockers[@]})); then
|
||||||
|
printf 'Refusing to start: production model container(s) still running: %s\n' "${blockers[*]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker ps --format '{{.Names}}' | grep -qx "$NAME"; then
|
||||||
|
echo "Refusing to replace a running A/B container; run ./stop-case.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 0755 "$RESULT_DIR"
|
||||||
|
|
||||||
|
args=(
|
||||||
|
--model "$CONTAINER_MODEL"
|
||||||
|
--alias "$MODEL_ALIAS"
|
||||||
|
--ctx-size "$CONTEXT"
|
||||||
|
--flash-attn on
|
||||||
|
--cache-type-k q4_0
|
||||||
|
--cache-type-v q4_0
|
||||||
|
--cache-prompt
|
||||||
|
--cache-ram 8192
|
||||||
|
--threads 6
|
||||||
|
--threads-batch 6
|
||||||
|
--batch-size 64
|
||||||
|
--ubatch-size 32
|
||||||
|
--parallel 1
|
||||||
|
--jinja
|
||||||
|
--reasoning auto
|
||||||
|
--reasoning-budget 8192
|
||||||
|
--reasoning-preserve
|
||||||
|
--host 127.0.0.1
|
||||||
|
--port 5005
|
||||||
|
--metrics
|
||||||
|
--fit off
|
||||||
|
--n-gpu-layers all
|
||||||
|
--no-mmap
|
||||||
|
--temperature 1.0
|
||||||
|
--top-p 0.95
|
||||||
|
--top-k 20
|
||||||
|
--device CUDA0,CUDA1
|
||||||
|
--main-gpu 0
|
||||||
|
--split-mode layer
|
||||||
|
--tensor-split "$SPLIT"
|
||||||
|
--spec-type draft-mtp
|
||||||
|
--spec-draft-n-max 3
|
||||||
|
--spec-draft-type-k f16
|
||||||
|
--spec-draft-type-v f16
|
||||||
|
)
|
||||||
|
|
||||||
|
label="$CANDIDATE-ctx${CONTEXT}-split${SPLIT//,/-}"
|
||||||
|
docker run -d \
|
||||||
|
--name "$NAME" \
|
||||||
|
--gpus all \
|
||||||
|
--network host \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=256m \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--pids-limit 1024 \
|
||||||
|
--log-opt max-size=20m \
|
||||||
|
--log-opt max-file=2 \
|
||||||
|
-v "$MODEL_ROOT":/models:ro \
|
||||||
|
-v "$RESULT_DIR":/results \
|
||||||
|
--label mike-ai.experiment=qwen38-ab-20260907 \
|
||||||
|
--label mike-ai.candidate="$CANDIDATE" \
|
||||||
|
--label mike-ai.case="$label" \
|
||||||
|
"$IMAGE" "${args[@]}"
|
||||||
|
|
||||||
|
printf 'Started isolated case %s on http://127.0.0.1:5005\n' "$label"
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
docker rm -f mike-ai-llama-qwen38-ab >/dev/null 2>&1 || true
|
||||||
|
echo "A/B test container removed. Production was not changed."
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
NAME=mike-ai-llama-qwen38-ab
|
||||||
|
deadline=$((SECONDS + ${READY_TIMEOUT:-900}))
|
||||||
|
until curl -fsS --max-time 3 http://127.0.0.1:5005/health >/dev/null; do
|
||||||
|
state=$(docker inspect -f '{{.State.Running}}' "$NAME" 2>/dev/null || true)
|
||||||
|
if [[ $state != true ]]; then
|
||||||
|
docker logs --tail 100 "$NAME" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ((SECONDS >= deadline)); then
|
||||||
|
docker logs --tail 100 "$NAME" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
curl -fsS http://127.0.0.1:5005/props
|
||||||
|
printf '\nA/B test server is ready.\n'
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
FROM mike-ai/bs-roformer-separator:0.47.0
|
||||||
|
|
||||||
|
ARG AMPHION_COMMIT=26f6883110181f1dbfe95c70a7c7dbaf4de5f42a
|
||||||
|
|
||||||
|
USER root
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git espeak-ng \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN git clone https://github.com/open-mmlab/Amphion.git /opt/amphion \
|
||||||
|
&& cd /opt/amphion \
|
||||||
|
&& git checkout "${AMPHION_COMMIT}" \
|
||||||
|
&& rm -rf .git
|
||||||
|
|
||||||
|
# Vevo2 is tested on Athena with the CUDA/PyTorch stack inherited from the
|
||||||
|
# existing audio worker. Do not install Amphion's historical torch 2.0/cu118
|
||||||
|
# pins: Blackwell requires the newer cu128 runtime already present here.
|
||||||
|
RUN python -m pip install --no-cache-dir \
|
||||||
|
accelerate==1.10.1 \
|
||||||
|
diffusers==0.35.1 \
|
||||||
|
einops==0.8.1 \
|
||||||
|
easydict==1.13 \
|
||||||
|
g2p_en==2.1.0 \
|
||||||
|
humanfriendly==10.0 \
|
||||||
|
huggingface-hub==0.34.4 \
|
||||||
|
hydra-core==1.3.2 \
|
||||||
|
inflect==7.5.0 \
|
||||||
|
ipython==9.5.0 \
|
||||||
|
json5==0.12.1 \
|
||||||
|
librosa==0.11.0 \
|
||||||
|
loguru==0.7.3 \
|
||||||
|
matplotlib==3.10.6 \
|
||||||
|
munch==4.0.0 \
|
||||||
|
omegaconf==2.3.0 \
|
||||||
|
openai-whisper==20250625 \
|
||||||
|
phonemizer==3.3.0 \
|
||||||
|
python-multipart==0.0.20 \
|
||||||
|
praat-parselmouth==0.4.6 \
|
||||||
|
pypinyin==0.55.0 \
|
||||||
|
pyworld==0.3.5 \
|
||||||
|
ruamel.yaml==0.18.15 \
|
||||||
|
safetensors==0.6.2 \
|
||||||
|
tabulate==0.9.0 \
|
||||||
|
tgt==1.5 \
|
||||||
|
torchcrepe==0.0.24 \
|
||||||
|
transformers==4.56.1 \
|
||||||
|
typeguard==4.4.4 \
|
||||||
|
unidecode==1.4.0 \
|
||||||
|
vector-quantize-pytorch==1.12.5 \
|
||||||
|
vocos==0.1.0
|
||||||
|
|
||||||
|
WORKDIR /opt/amphion
|
||||||
|
ENV PYTHONPATH=/opt/amphion \
|
||||||
|
HF_HOME=/models/huggingface \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
COPY run_fm_test.py /usr/local/bin/run_fm_test.py
|
||||||
|
COPY app.py /app/app.py
|
||||||
|
COPY index.html /app/index.html
|
||||||
|
|
||||||
|
EXPOSE 8008
|
||||||
|
HEALTHCHECK --interval=5s --timeout=3s --start-period=90s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8008/health || exit 1
|
||||||
|
ENTRYPOINT ["python", "/app/app.py"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Vevo2 Voice Conversion on Athena
|
||||||
|
|
||||||
|
This directory contains Athena's private Vevo2 voice-conversion studio.
|
||||||
|
|
||||||
|
- Code: `open-mmlab/Amphion` commit
|
||||||
|
`26f6883110181f1dbfe95c70a7c7dbaf4de5f42a` (MIT)
|
||||||
|
- Weights: `RMSnow/Vevo2` (CC BY-NC-ND 4.0)
|
||||||
|
- Runtime: PyTorch 2.7.1 + CUDA 12.8 inherited from Athena's tested audio
|
||||||
|
separator image; the historical Amphion torch 2.0/cu118 pins are not used.
|
||||||
|
- Storage: `/data/voice/vevo2`; removing that directory and the test image
|
||||||
|
removes all downloaded artifacts.
|
||||||
|
- Private UI: `http://192.168.1.212:8008` through WireGuard only.
|
||||||
|
- Output: uncompressed mono WAV, 24 kHz.
|
||||||
|
|
||||||
|
The 9 September technical gate converted the official 8.6-second speech sample
|
||||||
|
through the production HTTP API in 2.342 seconds. A warm service start loaded
|
||||||
|
the model in 12.216 seconds, and peak CUDA allocation during conversion was
|
||||||
|
5605.6 MiB. The returned 24-kHz WAV was 412878 bytes and 8.6 seconds long. The
|
||||||
|
service stores named reference voices, accepts a source clip, and returns a
|
||||||
|
transient WAV download. Jobs and generated outputs are removed after delivery.
|
||||||
|
It is deliberately not exposed on the university interface.
|
||||||
|
|
||||||
|
The installed Compose project is named `mike-ai-voice`. Rebuild or recreate it
|
||||||
|
with an explicit `VOICE_GPU_UUID` so Docker keeps the service attached to the
|
||||||
|
RTX 5080. The profile controller starts and stops the existing container; it
|
||||||
|
does not rebuild it during a mode switch.
|
||||||
|
|
||||||
|
The weights are licensed CC BY-NC-ND 4.0. This deployment is for Mike's
|
||||||
|
private, non-commercial use only. Do not use or expose it as a public or
|
||||||
|
commercial voice-cloning service.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small local-only Vevo2 voice-conversion studio for Athena."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
|
import models.svc.vevo2.infer_vevo2_fm as vevo
|
||||||
|
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.environ.get("VOICE_DATA_DIR", "/data"))
|
||||||
|
PROFILE_DIR = DATA_DIR / "profiles"
|
||||||
|
JOB_DIR = DATA_DIR / "jobs"
|
||||||
|
INDEX = Path("/app/index.html")
|
||||||
|
MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_BYTES", str(100 * 1024 * 1024)))
|
||||||
|
MODEL_LOCK = threading.Lock()
|
||||||
|
PIPELINE = None
|
||||||
|
MODEL_LOAD_SECONDS: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(value: str) -> str:
|
||||||
|
value = re.sub(r"[^A-Za-z0-9_. -]+", "-", value.strip())
|
||||||
|
value = re.sub(r"\s+", "-", value).strip("-.")
|
||||||
|
return value[:64] or "voice"
|
||||||
|
|
||||||
|
|
||||||
|
def load_pipeline() -> None:
|
||||||
|
global PIPELINE, MODEL_LOAD_SECONDS
|
||||||
|
if PIPELINE is not None:
|
||||||
|
return
|
||||||
|
with MODEL_LOCK:
|
||||||
|
if PIPELINE is not None:
|
||||||
|
return
|
||||||
|
started = time.monotonic()
|
||||||
|
PIPELINE = vevo.load_inference_pipeline()
|
||||||
|
vevo.inference_pipeline = PIPELINE
|
||||||
|
MODEL_LOAD_SECONDS = time.monotonic() - started
|
||||||
|
|
||||||
|
|
||||||
|
def to_wav(source: Path, target: Path) -> None:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(source),
|
||||||
|
"-ac", "1", "-ar", "24000", "-c:a", "pcm_s16le", str(target)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=180,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode:
|
||||||
|
raise ValueError(completed.stderr.strip() or "Audio konnte nicht gelesen werden")
|
||||||
|
|
||||||
|
|
||||||
|
async def save_upload(upload: UploadFile, target: Path) -> None:
|
||||||
|
size = 0
|
||||||
|
with target.open("wb") as handle:
|
||||||
|
while chunk := await upload.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(413, "Audiodatei ist zu groß")
|
||||||
|
handle.write(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_path(name: str) -> Path:
|
||||||
|
target = PROFILE_DIR / f"{safe_name(name)}.wav"
|
||||||
|
if not target.is_file():
|
||||||
|
raise HTTPException(404, "Referenzstimme wurde nicht gefunden")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def run_conversion(source: Path, reference: Path, output: Path, pitch_shift: bool) -> tuple[float, float]:
|
||||||
|
"""Run the GPU-bound conversion off the API event loop."""
|
||||||
|
load_pipeline()
|
||||||
|
with MODEL_LOCK:
|
||||||
|
torch.cuda.reset_peak_memory_stats()
|
||||||
|
started = time.monotonic()
|
||||||
|
vevo.vevo2_fm(str(source), str(reference), str(output), shifted_src=pitch_shift)
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
peak = torch.cuda.max_memory_allocated() / 1048576
|
||||||
|
return elapsed, peak
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
JOB_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
load_pipeline()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Athena Voice Studio", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index() -> str:
|
||||||
|
return INDEX.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {
|
||||||
|
"status": "ok" if PIPELINE is not None else "starting",
|
||||||
|
"model": "RMSnow/Vevo2",
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"model_load_seconds": MODEL_LOAD_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/profiles")
|
||||||
|
def profiles() -> dict:
|
||||||
|
return {"profiles": [path.stem for path in sorted(PROFILE_DIR.glob("*.wav"))]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/profiles")
|
||||||
|
async def create_profile(
|
||||||
|
name: str = Form(...),
|
||||||
|
consent: bool = Form(False),
|
||||||
|
audio: UploadFile = File(...),
|
||||||
|
) -> dict:
|
||||||
|
if not consent:
|
||||||
|
raise HTTPException(400, "Bestätige, dass du die Stimme verwenden darfst")
|
||||||
|
clean = safe_name(name)
|
||||||
|
job = JOB_DIR / f"profile-{uuid.uuid4().hex}"
|
||||||
|
job.mkdir(parents=True)
|
||||||
|
raw = job / f"upload-{safe_name(audio.filename or 'reference.audio')}"
|
||||||
|
try:
|
||||||
|
await save_upload(audio, raw)
|
||||||
|
target = PROFILE_DIR / f"{clean}.wav"
|
||||||
|
temporary = job / "reference.wav"
|
||||||
|
to_wav(raw, temporary)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
return {"status": "ok", "profile": clean}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(job, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/profiles/{name}")
|
||||||
|
def delete_profile(name: str) -> dict:
|
||||||
|
target = profile_path(name)
|
||||||
|
target.unlink()
|
||||||
|
return {"status": "ok", "profile": target.stem}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/convert")
|
||||||
|
async def convert(
|
||||||
|
source: UploadFile = File(...),
|
||||||
|
profile: str = Form(...),
|
||||||
|
pitch_shift: bool = Form(True),
|
||||||
|
) -> FileResponse:
|
||||||
|
reference = profile_path(profile)
|
||||||
|
job_id = uuid.uuid4().hex
|
||||||
|
job = JOB_DIR / f"convert-{job_id}"
|
||||||
|
job.mkdir(parents=True)
|
||||||
|
raw = job / f"upload-{safe_name(source.filename or 'source.audio')}"
|
||||||
|
source_wav = job / "source.wav"
|
||||||
|
output = JOB_DIR / f"voice-{job_id}.wav"
|
||||||
|
try:
|
||||||
|
await save_upload(source, raw)
|
||||||
|
to_wav(raw, source_wav)
|
||||||
|
elapsed, peak = await asyncio.to_thread(
|
||||||
|
run_conversion, source_wav, reference, output, pitch_shift
|
||||||
|
)
|
||||||
|
return FileResponse(
|
||||||
|
output,
|
||||||
|
media_type="audio/wav",
|
||||||
|
filename=f"{safe_name(profile)}-{job_id[:8]}.wav",
|
||||||
|
headers={
|
||||||
|
"X-Conversion-Seconds": f"{elapsed:.3f}",
|
||||||
|
"X-Peak-VRAM-MiB": f"{peak:.1f}",
|
||||||
|
},
|
||||||
|
background=BackgroundTask(output.unlink, missing_ok=True),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
output.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(500, f"Stimmenwandlung fehlgeschlagen: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(job, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8008)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
voice-studio:
|
||||||
|
build: .
|
||||||
|
image: mike-ai/vevo2-voice-studio:0.1
|
||||||
|
container_name: mike-ai-voice-studio
|
||||||
|
restart: "no"
|
||||||
|
labels:
|
||||||
|
com.mike-ai.voice-worker: vevo2
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
VOICE_DATA_DIR: /data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8008:8008"
|
||||||
|
volumes:
|
||||||
|
- /data/voice/vevo2/huggingface/hub/Vevo2:/opt/amphion/ckpts/Vevo2
|
||||||
|
- /data/voice/vevo2/huggingface:/models/huggingface
|
||||||
|
- /data/voice/vevo2/whisper:/root/.cache/whisper
|
||||||
|
- /data/voice/studio:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8008/health | grep -q '\"status\":\"ok\"'"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 600s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
networks:
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
name: mike-ai_frontend
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Athena Voice Studio</title><style>
|
||||||
|
:root{color-scheme:dark;--bg:#07101a;--panel:#0d1926;--line:#20374d;--text:#edf6ff;--muted:#91a7bb;--cyan:#45d7ff;--green:#66e3a4;--red:#ff7185}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,#12283d,var(--bg) 45%);color:var(--text);font:16px system-ui,sans-serif}main{max-width:960px;margin:auto;padding:32px 18px}h1{margin:.2rem 0}p{color:var(--muted)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:20px}label{display:block;margin:13px 0 6px;color:var(--muted)}input,select,button{width:100%;border:1px solid var(--line);border-radius:9px;padding:11px;background:#07111d;color:var(--text);font:inherit}button{margin-top:15px;background:#10334a;border-color:var(--cyan);cursor:pointer}button:disabled{opacity:.5;cursor:wait}.row{display:flex;align-items:center;gap:9px}.row input{width:auto}.status{min-height:24px;margin-top:12px;color:var(--green)}.error{color:var(--red)}audio{width:100%;margin-top:12px}@media(max-width:700px){.grid{grid-template-columns:1fr}}</style></head>
|
||||||
|
<body><main><div style="color:var(--cyan);letter-spacing:.14em;text-transform:uppercase;font-size:12px">Mike AI · lokal auf Athena</div><h1>Voice Studio</h1><p>Eine Stimme als Referenz speichern und eine vorhandene Sprach- oder Gesangsaufnahme in diese Stimme übertragen. Ausgabe: unkomprimiertes WAV mit 24 kHz.</p>
|
||||||
|
<div class="grid"><section class="card"><h2>1 · Referenzstimme</h2><form id="profileForm"><label>Name</label><input name="name" required placeholder="z. B. Meine Stimme"><label>Saubere Referenzaufnahme</label><input name="audio" type="file" accept="audio/*" required><label class="row"><input name="consent" type="checkbox" value="true" required><span>Ich darf diese Stimme verwenden.</span></label><button>Stimme speichern</button></form><div class="status" id="profileStatus"></div></section>
|
||||||
|
<section class="card"><h2>2 · Stimme übertragen</h2><form id="convertForm"><label>Zielstimme</label><select name="profile" id="profiles" required></select><label>Quellaufnahme</label><input name="source" type="file" accept="audio/*" required><label class="row"><input name="pitch_shift" type="checkbox" value="true" checked><span>Tonlage automatisch an Zielstimme anpassen</span></label><button>WAV erzeugen</button></form><div class="status" id="convertStatus"></div><audio id="player" controls hidden></audio><a id="download" hidden>WAV herunterladen</a></section></div>
|
||||||
|
<p>Hinweis: Nur privat und mit Zustimmung verwenden. Das Vevo2-Gewicht ist nicht für kommerzielle Nutzung lizenziert.</p></main><script>
|
||||||
|
const $=id=>document.getElementById(id);async function loadProfiles(){let r=await fetch('/api/profiles'),d=await r.json();$('profiles').innerHTML=d.profiles.length?d.profiles.map(x=>`<option>${x}</option>`).join(''):'<option value="">Noch keine Stimme gespeichert</option>'}function state(id,text,error=false){let e=$(id);e.textContent=text;e.classList.toggle('error',error)}$('profileForm').onsubmit=async e=>{e.preventDefault();let b=e.submitter;b.disabled=true;state('profileStatus','Referenz wird geprüft und gespeichert …');try{let r=await fetch('/api/profiles',{method:'POST',body:new FormData(e.target)}),d=await r.json();if(!r.ok)throw Error(d.detail||`HTTP ${r.status}`);state('profileStatus',`„${d.profile}“ ist gespeichert.`);await loadProfiles()}catch(x){state('profileStatus',x.message,true)}finally{b.disabled=false}};$('convertForm').onsubmit=async e=>{e.preventDefault();let b=e.submitter;b.disabled=true;state('convertStatus','Modell arbeitet …');$('player').hidden=true;$('download').hidden=true;try{let f=new FormData(e.target);if(!f.has('pitch_shift'))f.append('pitch_shift','false');let r=await fetch('/api/convert',{method:'POST',body:f});if(!r.ok){let d=await r.json();throw Error(d.detail||`HTTP ${r.status}`)}let blob=await r.blob(),url=URL.createObjectURL(blob);$('player').src=url;$('player').hidden=false;$('download').href=url;$('download').download='voice-conversion.wav';$('download').hidden=false;state('convertStatus',`Fertig in ${r.headers.get('X-Conversion-Seconds')||'?'} s · Spitzen-VRAM ${r.headers.get('X-Peak-VRAM-MiB')||'?'} MiB`)}catch(x){state('convertStatus',x.message,true)}finally{b.disabled=false}};loadProfiles();
|
||||||
|
</script></body></html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal reproducible Vevo2 style-preserving voice-conversion smoke test."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
import models.svc.vevo2.infer_vevo2_fm as vevo
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--source", required=True)
|
||||||
|
parser.add_argument("--reference", required=True)
|
||||||
|
parser.add_argument("--output", required=True)
|
||||||
|
parser.add_argument("--no-pitch-shift", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
|
||||||
|
started = time.monotonic()
|
||||||
|
vevo.inference_pipeline = vevo.load_inference_pipeline()
|
||||||
|
loaded = time.monotonic()
|
||||||
|
vevo.vevo2_fm(
|
||||||
|
args.source,
|
||||||
|
args.reference,
|
||||||
|
args.output,
|
||||||
|
shifted_src=not args.no_pitch_shift,
|
||||||
|
)
|
||||||
|
finished = time.monotonic()
|
||||||
|
print(
|
||||||
|
{
|
||||||
|
"model_load_seconds": round(loaded - started, 3),
|
||||||
|
"conversion_seconds": round(finished - loaded, 3),
|
||||||
|
"total_seconds": round(finished - started, 3),
|
||||||
|
"peak_vram_mib": round(torch.cuda.max_memory_allocated() / 1048576, 1),
|
||||||
|
"output": args.output,
|
||||||
|
},
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
FROM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu24.04
|
||||||
|
|
||||||
|
ARG XVC_COMMIT=49df8c591eafc48b096e466d96f9839f9c0dd739
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl ffmpeg git libsndfile1 python3 python3-pip python3-venv \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN git clone https://github.com/Jerrister/X-VC.git /opt/xvc \
|
||||||
|
&& cd /opt/xvc \
|
||||||
|
&& git checkout "${XVC_COMMIT}" \
|
||||||
|
&& rm -rf .git
|
||||||
|
|
||||||
|
RUN python3 -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:${PATH}"
|
||||||
|
|
||||||
|
# RTX 5080: use a CUDA 12.8 PyTorch build instead of X-VC's older training pin.
|
||||||
|
RUN python -m pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& python -m pip install --no-cache-dir \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu128 \
|
||||||
|
"torch==2.8.0" "torchaudio==2.8.0" \
|
||||||
|
&& python -m pip install --no-cache-dir \
|
||||||
|
"gradio>=5.49,<6" "huggingface_hub>=0.34,<2" \
|
||||||
|
"transformers>=4.44,<5" "hydra-core>=1.3,<2" "omegaconf>=2.3,<3" \
|
||||||
|
"x-transformers>=1.40,<3" "einops>=0.8,<1" "einx>=0.3,<1" \
|
||||||
|
"numpy>=1.26,<3" "scipy>=1.13,<2" "soundfile>=0.12,<1" \
|
||||||
|
"soxr>=0.5,<1" "tqdm>=4.66,<5" "wandb>=0.18,<1"
|
||||||
|
|
||||||
|
RUN python -m pip install --no-cache-dir "descript-audiotools==0.7.2"
|
||||||
|
|
||||||
|
# Resemble Enhance declares old training-time pins for PyTorch, Gradio and
|
||||||
|
# DeepSpeed. Install only its inference code plus the small modules imported by
|
||||||
|
# that path, then remove the two unnecessary training imports. X-VC keeps the
|
||||||
|
# CUDA 12.8 / PyTorch 2.8 runtime required by the RTX 5080.
|
||||||
|
RUN python -m pip install --no-cache-dir --no-deps "resemble-enhance==0.0.1" \
|
||||||
|
&& python -m pip install --no-cache-dir \
|
||||||
|
"numpy==1.26.4" "scipy==1.11.4" \
|
||||||
|
"librosa==0.10.1" "soundfile==0.12.1" \
|
||||||
|
"matplotlib>=3.8,<4" "pandas>=2.1,<3" "rich>=13,<15" "tabulate>=0.9,<1"
|
||||||
|
|
||||||
|
COPY patch_resemble_enhance.py /tmp/patch_resemble_enhance.py
|
||||||
|
RUN python /tmp/patch_resemble_enhance.py && rm /tmp/patch_resemble_enhance.py
|
||||||
|
|
||||||
|
COPY app.py /opt/xvc/local_webui.py
|
||||||
|
COPY inference_log.py /opt/xvc/utils/log.py
|
||||||
|
|
||||||
|
ENV HF_HOME=/models/huggingface \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
EXPOSE 8009
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=5s --timeout=3s --start-period=600s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8009/ >/dev/null || exit 1
|
||||||
|
|
||||||
|
CMD ["python", "/opt/xvc/local_webui.py"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# X-VC voice conversion on Athena
|
||||||
|
|
||||||
|
Isolated quality gate for `chenxie95/X-VC`, using the official inference path
|
||||||
|
from commit `49df8c591eafc48b096e466d96f9839f9c0dd739`. The UI is adapted from the
|
||||||
|
public Hugging Face Space at commit
|
||||||
|
`d761cd6421e85376b2656dfefd8471d7f35a42be` and runs locally without
|
||||||
|
ZeroGPU.
|
||||||
|
|
||||||
|
- Private URL: `http://192.168.1.212:8009`
|
||||||
|
- Source clip: speech content and timing to preserve
|
||||||
|
- Reference clip: target speaker identity
|
||||||
|
- Output: native 16 kHz PCM WAV plus optional Resemble-Enhance restoration at 44.1 kHz
|
||||||
|
- GPU: RTX 5080 only
|
||||||
|
- Persistent cache: `/data/voice/xvc/huggingface`
|
||||||
|
- Code and model license: MIT
|
||||||
|
|
||||||
|
The semantic tokenizer documents Chinese and English. German is therefore a
|
||||||
|
quality gate, not an assumed supported language. Keep OmniVoice installed: it
|
||||||
|
does text-to-speech cloning, while X-VC tests true audio-to-audio conversion.
|
||||||
|
|
||||||
|
Technical acceptance on 9 September 2026 used the repository's source and
|
||||||
|
target examples: 5.20 seconds were converted in 1.07 seconds (RTF 0.21). The
|
||||||
|
result was valid mono PCM WAV at 16 kHz, and the loaded process occupied about
|
||||||
|
2.9 GiB on the RTX 5080. German listening quality remains open.
|
||||||
|
|
||||||
|
The optional high-quality path uses `resemble-enhance` 0.0.1 with model
|
||||||
|
revision `4e3510ce4a8391159f665903544c5150bee7b2cb`. It does not change X-VC's
|
||||||
|
native 16-kHz architecture. Instead, it reconstructs missing speech bandwidth
|
||||||
|
after conversion and writes a second 44.1-kHz WAV. The UI always retains the
|
||||||
|
native output for an honest A/B comparison.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Local Athena adaptation of the public X-VC Gradio demo."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
import gradio as gr
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
import torch
|
||||||
|
from huggingface_hub import hf_hub_download
|
||||||
|
from omegaconf import OmegaConf
|
||||||
|
|
||||||
|
HERE = "/opt/xvc"
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
|
||||||
|
from bins.infer_utils import precompute_conditions, run_offline, run_streaming, to_numpy_audio
|
||||||
|
from models.codec.sac.model import XVC
|
||||||
|
from utils.audio import audio_highpass_filter, audio_volume_normalize, load_audio
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
log = logging.getLogger("xvc-local")
|
||||||
|
|
||||||
|
MODEL_REPO = "chenxie95/X-VC"
|
||||||
|
SPACE_REPO = "hugging-apps/x-vc-voice-conversion"
|
||||||
|
SPEAKER_SUBDIR = "pretrained/speech_eres2net_sv_en_voxceleb_16k"
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
ENHANCED_SAMPLE_RATE = 44100
|
||||||
|
LATENT_HOP_LENGTH = 1280
|
||||||
|
MAX_SECONDS = 20.0
|
||||||
|
RESEMBLE_REVISION = "4e3510ce4a8391159f665903544c5150bee7b2cb"
|
||||||
|
RESEMBLE_RUN_DIR = Path("/models/huggingface/resemble-enhance/enhancer_stage2")
|
||||||
|
MODE_OFFLINE = "Offline (höchste Qualität)"
|
||||||
|
MODE_STREAMING = "Streaming (simulierte Echtzeit)"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_model() -> XVC:
|
||||||
|
speaker_config = hf_hub_download(
|
||||||
|
repo_id=SPACE_REPO,
|
||||||
|
repo_type="space",
|
||||||
|
filename=f"{SPEAKER_SUBDIR}/configuration.json",
|
||||||
|
)
|
||||||
|
hf_hub_download(
|
||||||
|
repo_id=SPACE_REPO,
|
||||||
|
repo_type="space",
|
||||||
|
filename=f"{SPEAKER_SUBDIR}/pretrained_eres2net.ckpt",
|
||||||
|
)
|
||||||
|
checkpoint = hf_hub_download(repo_id=MODEL_REPO, filename="xvc.pt")
|
||||||
|
|
||||||
|
cfg = OmegaConf.load(os.path.join(HERE, "configs", "xvc.yaml"))
|
||||||
|
cfg["model"]["generator"].pop("loss_config", None)
|
||||||
|
cfg["model"].pop("discriminator", None)
|
||||||
|
cfg["model"]["generator"]["speaker_encoder"]["pretrained_dir"] = os.path.dirname(speaker_config)
|
||||||
|
infer_cfg = os.path.join(tempfile.gettempdir(), "xvc_inference.yaml")
|
||||||
|
OmegaConf.save(cfg, infer_cfg)
|
||||||
|
|
||||||
|
loaded = XVC.load_from_checkpoint(infer_cfg, checkpoint, device=torch.device("cuda"))
|
||||||
|
loaded.remove_weight_norm()
|
||||||
|
loaded = loaded.eval().to("cuda")
|
||||||
|
log.info("X-VC model ready on %s", torch.cuda.get_device_name(0))
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
MODEL = _load_model()
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_wav(path: str) -> np.ndarray:
|
||||||
|
wav = load_audio(path, sampling_rate=SAMPLE_RATE, volume_normalize=False)
|
||||||
|
if wav is None or len(wav) == 0:
|
||||||
|
raise gr.Error("Die Audiodatei konnte nicht gelesen werden.")
|
||||||
|
wav = wav[: int(MAX_SECONDS * SAMPLE_RATE)]
|
||||||
|
wav = audio_volume_normalize(wav)
|
||||||
|
wav = audio_highpass_filter(wav, SAMPLE_RATE, 40)
|
||||||
|
remainder = len(wav) % LATENT_HOP_LENGTH
|
||||||
|
if remainder:
|
||||||
|
wav = np.pad(wav, (0, LATENT_HOP_LENGTH - remainder), mode="constant")
|
||||||
|
return wav.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _tensor(wav: np.ndarray) -> torch.Tensor:
|
||||||
|
return torch.from_numpy(wav).unsqueeze(0).unsqueeze(1).float().to("cuda")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_wav(audio: np.ndarray, sample_rate: int = SAMPLE_RATE, suffix: str = "native-16k") -> str:
|
||||||
|
os.makedirs("/output", exist_ok=True)
|
||||||
|
path = os.path.join("/output", f"xvc-{suffix}-{int(time.time() * 1000)}.wav")
|
||||||
|
sf.write(path, np.clip(np.asarray(audio, dtype=np.float32), -1.0, 1.0), sample_rate, subtype="PCM_16")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@torch.inference_mode()
|
||||||
|
def _enhance_wav(audio: np.ndarray) -> tuple[str, float]:
|
||||||
|
if not (RESEMBLE_RUN_DIR / "hparams.yaml").is_file():
|
||||||
|
raise gr.Error("Resemble-Enhance-Gewichte fehlen; bitte den Athena-Operator prüfen lassen.")
|
||||||
|
|
||||||
|
from resemble_enhance.enhancer.inference import enhance
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
source = torch.from_numpy(np.asarray(audio, dtype=np.float32).reshape(-1))
|
||||||
|
restored, sample_rate = enhance(
|
||||||
|
source,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
"cuda",
|
||||||
|
nfe=32,
|
||||||
|
solver="midpoint",
|
||||||
|
lambd=0.1,
|
||||||
|
tau=0.5,
|
||||||
|
run_dir=RESEMBLE_RUN_DIR,
|
||||||
|
)
|
||||||
|
if int(sample_rate) != ENHANCED_SAMPLE_RATE:
|
||||||
|
raise RuntimeError(f"Unerwartete Resemble-Enhance-Abtastrate: {sample_rate}")
|
||||||
|
return _write_wav(restored.cpu().numpy(), int(sample_rate), "enhanced-44k1"), time.time() - started
|
||||||
|
|
||||||
|
|
||||||
|
@torch.inference_mode()
|
||||||
|
def convert(
|
||||||
|
source_audio: str,
|
||||||
|
reference_audio: str,
|
||||||
|
enhance_44k1: bool = True,
|
||||||
|
mode: str = MODE_OFFLINE,
|
||||||
|
chunk_ms: int = 2400,
|
||||||
|
current_ms: int = 120,
|
||||||
|
future_ms: int = 100,
|
||||||
|
smooth_ms: int = 20,
|
||||||
|
progress=gr.Progress(track_tqdm=True),
|
||||||
|
) -> Tuple[str, str | None, str]:
|
||||||
|
if not source_audio:
|
||||||
|
raise gr.Error("Bitte eine Quelldatei mit dem zu erhaltenden Inhalt hochladen.")
|
||||||
|
if not reference_audio:
|
||||||
|
raise gr.Error("Bitte eine Referenzdatei mit der Zielstimme hochladen.")
|
||||||
|
|
||||||
|
source_np = _prepare_wav(source_audio)
|
||||||
|
reference_np = _prepare_wav(reference_audio)
|
||||||
|
source_wav = _tensor(source_np)
|
||||||
|
target_wav = _tensor(reference_np)
|
||||||
|
seconds = len(source_np) / SAMPLE_RATE
|
||||||
|
started = time.time()
|
||||||
|
|
||||||
|
if mode == MODE_STREAMING:
|
||||||
|
history_ms = int(chunk_ms) - int(current_ms) - int(smooth_ms) - int(future_ms)
|
||||||
|
if history_ms < 0:
|
||||||
|
raise gr.Error("Fenster muss mindestens Current + Lookahead + Crossfade umfassen.")
|
||||||
|
speaker_condition, frame_condition = precompute_conditions(MODEL, target_wav, target_wav)
|
||||||
|
recon, latency_ms = run_streaming(
|
||||||
|
model=MODEL,
|
||||||
|
source_wav=source_wav,
|
||||||
|
speaker_condition=speaker_condition,
|
||||||
|
frame_condition=frame_condition,
|
||||||
|
sample_rate=SAMPLE_RATE,
|
||||||
|
chunk_ms=int(chunk_ms),
|
||||||
|
current_ms=int(current_ms),
|
||||||
|
future_ms=int(future_ms),
|
||||||
|
smooth_ms=int(smooth_ms),
|
||||||
|
)
|
||||||
|
elapsed = time.time() - started
|
||||||
|
latency = np.asarray(latency_ms, dtype=np.float64)
|
||||||
|
report = (
|
||||||
|
f"**Streaming** · {len(latency)} Chunks · Mittel **{latency.mean():.0f} ms**, "
|
||||||
|
f"P95 **{np.percentile(latency, 95):.0f} ms** · insgesamt {elapsed:.2f} s "
|
||||||
|
f"für {seconds:.2f} s Audio (RTF {elapsed / seconds:.2f})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
recon = run_offline(MODEL, source_wav, target_wav, target_wav)
|
||||||
|
elapsed = time.time() - started
|
||||||
|
report = (
|
||||||
|
f"**Offline** · {seconds:.2f} s Audio in {elapsed:.2f} s "
|
||||||
|
f"(RTF {elapsed / seconds:.2f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
recon_np = to_numpy_audio(recon)
|
||||||
|
native_path = _write_wav(recon_np)
|
||||||
|
enhanced_path = None
|
||||||
|
if enhance_44k1:
|
||||||
|
del source_wav, target_wav, recon
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
enhanced_path, enhancement_seconds = _enhance_wav(recon_np)
|
||||||
|
report += (
|
||||||
|
f" · Resemble Enhance **{enhancement_seconds:.2f} s**, "
|
||||||
|
f"Ausgabe **44,1 kHz** (rekonstruierte Bandbreite)"
|
||||||
|
)
|
||||||
|
return native_path, enhanced_path, report
|
||||||
|
|
||||||
|
|
||||||
|
CSS = "#col-container { max-width: 1100px; margin: 0 auto; }"
|
||||||
|
HEADER = """# X-VC — Voice Changer
|
||||||
|
|
||||||
|
Die **Quelle** liefert Text, Aussprache und Timing. Die **Referenz** liefert die
|
||||||
|
Zielstimme. X-VC arbeitet Audio-zu-Audio ohne Transkript oder Training.
|
||||||
|
|
||||||
|
[Paper](https://arxiv.org/abs/2604.12456) · [Modell](https://huggingface.co/chenxie95/X-VC) ·
|
||||||
|
[Code](https://github.com/Jerrister/X-VC)
|
||||||
|
"""
|
||||||
|
|
||||||
|
with gr.Blocks(title="X-VC Voice Changer", theme=gr.themes.Citrus(), css=CSS) as demo:
|
||||||
|
with gr.Column(elem_id="col-container"):
|
||||||
|
gr.Markdown(HEADER)
|
||||||
|
with gr.Row():
|
||||||
|
source = gr.Audio(label="Quelle — Inhalt und Sprechweise", type="filepath", sources=["upload", "microphone"])
|
||||||
|
reference = gr.Audio(label="Referenz — gewünschte Zielstimme", type="filepath", sources=["upload", "microphone"])
|
||||||
|
run = gr.Button("Stimme umwandeln", variant="primary")
|
||||||
|
enhance_44k1 = gr.Checkbox(
|
||||||
|
value=True,
|
||||||
|
label="Zusätzlich mit Resemble Enhance auf 44,1 kHz restaurieren",
|
||||||
|
info="Rekonstruiert fehlende Höhen per KI; das native 16-kHz-Ergebnis bleibt zum Vergleich erhalten.",
|
||||||
|
)
|
||||||
|
with gr.Row():
|
||||||
|
output_native = gr.Audio(label="X-VC Original · 16 kHz", type="filepath", autoplay=False)
|
||||||
|
output_enhanced = gr.Audio(label="Enhanced · 44,1 kHz", type="filepath", autoplay=False)
|
||||||
|
report = gr.Markdown()
|
||||||
|
with gr.Accordion("Erweiterte Einstellungen", open=False):
|
||||||
|
mode = gr.Radio([MODE_OFFLINE, MODE_STREAMING], value=MODE_OFFLINE, label="Verarbeitungsmodus")
|
||||||
|
with gr.Row():
|
||||||
|
current_ms = gr.Slider(40, 640, value=120, step=40, label="Aktueller Chunk (ms)")
|
||||||
|
chunk_ms = gr.Slider(800, 4800, value=2400, step=200, label="Gesamtfenster (ms)")
|
||||||
|
with gr.Row():
|
||||||
|
future_ms = gr.Slider(0, 400, value=100, step=20, label="Lookahead (ms)")
|
||||||
|
smooth_ms = gr.Slider(0, 100, value=20, step=10, label="Crossfade (ms)")
|
||||||
|
gr.Markdown(
|
||||||
|
"Die ersten 20 Sekunden jeder Datei werden verarbeitet. X-VC bleibt nativ bei 16 kHz; "
|
||||||
|
"die optionale zweite Datei rekonstruiert die Sprachbandbreite auf 44,1 kHz."
|
||||||
|
)
|
||||||
|
run.click(
|
||||||
|
convert,
|
||||||
|
inputs=[source, reference, enhance_44k1, mode, chunk_ms, current_ms, future_ms, smooth_ms],
|
||||||
|
outputs=[output_native, output_enhanced, report],
|
||||||
|
api_name="convert",
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
demo.queue(default_concurrency_limit=1).launch(
|
||||||
|
server_name="0.0.0.0",
|
||||||
|
server_port=8009,
|
||||||
|
show_error=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
xvc-studio:
|
||||||
|
build: .
|
||||||
|
image: mike-ai/xvc-studio:2026-09-09-enhance
|
||||||
|
container_name: mike-ai-xvc-studio
|
||||||
|
restart: "no"
|
||||||
|
labels:
|
||||||
|
com.mike-ai.voice-change-worker: xvc
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
HF_HOME: /models/huggingface
|
||||||
|
PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8009:8009"
|
||||||
|
volumes:
|
||||||
|
- /data/voice/xvc/huggingface:/models/huggingface
|
||||||
|
- /data/voice/xvc/output:/output
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8009/ >/dev/null"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 600s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["${VOICE_GPU_UUID:?set VOICE_GPU_UUID to the RTX 5080 UUID}"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
aliases: [xvc-studio]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
name: mike-ai_frontend
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Small inference-only replacement for X-VC's training logger.
|
||||||
|
|
||||||
|
The upstream logger imports WandB, Matplotlib and TensorBoard at module import
|
||||||
|
time although model inference only uses the normal logging functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
_logger = logging.getLogger("xvc")
|
||||||
|
|
||||||
|
debug = _logger.debug
|
||||||
|
info = _logger.info
|
||||||
|
warn = _logger.warning
|
||||||
|
warning = _logger.warning
|
||||||
|
error = _logger.error
|
||||||
|
|
||||||
|
|
||||||
|
def init(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_audio(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_loss(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Remove training-only imports from Resemble Enhance's inference path."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import resemble_enhance
|
||||||
|
|
||||||
|
|
||||||
|
root = Path(resemble_enhance.__file__).parent
|
||||||
|
|
||||||
|
replacements = {
|
||||||
|
root / "enhancer" / "inference.py": {
|
||||||
|
"from .train import Enhancer, HParams": (
|
||||||
|
"from .enhancer import Enhancer\nfrom .hparams import HParams"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
root / "denoiser" / "inference.py": {
|
||||||
|
"from .train import Denoiser, HParams": (
|
||||||
|
"from .denoiser import Denoiser\nfrom .hparams import HParams"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
root / "enhancer" / "enhancer.py": {
|
||||||
|
"from ..utils.distributed import global_leader_only\n"
|
||||||
|
"from ..utils.train_loop import TrainLoop": (
|
||||||
|
"def global_leader_only(fn):\n"
|
||||||
|
" return fn\n\n"
|
||||||
|
"class TrainLoop:\n"
|
||||||
|
" @classmethod\n"
|
||||||
|
" def get_running_loop(cls):\n"
|
||||||
|
" return None"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, edits in replacements.items():
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for old, new in edits.items():
|
||||||
|
if old not in text:
|
||||||
|
raise RuntimeError(f"Expected Resemble Enhance source not found in {path}: {old!r}")
|
||||||
|
text = text.replace(old, new, 1)
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
ARG YUE2_COMMIT=9c6c4b349be978b06a9d0d958471a07a6cdeff4d
|
||||||
|
ARG YUE2_WEBUI_COMMIT=8fc05609bde5dcd345d7c6d57fff3da0839164c5
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg git libsndfile1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN git clone https://github.com/multimodal-art-projection/YuE.git /opt/yue2 \
|
||||||
|
&& git -C /opt/yue2 checkout "${YUE2_COMMIT}" \
|
||||||
|
&& python -m pip install --no-cache-dir /opt/yue2
|
||||||
|
|
||||||
|
# The community console is intentionally layered onto the already verified,
|
||||||
|
# pinned YuE2 runtime. We use only its webui/ directory; its bundled YuE2 fork
|
||||||
|
# and Windows installers never become part of the Athena image.
|
||||||
|
RUN git clone https://github.com/Ladypoly/YuE2_WebUI.git /tmp/yue2-webui \
|
||||||
|
&& git -C /tmp/yue2-webui checkout "${YUE2_WEBUI_COMMIT}" \
|
||||||
|
&& cp -a /tmp/yue2-webui/webui /opt/yue2/webui \
|
||||||
|
&& cp /tmp/yue2-webui/LICENSE /opt/yue2/YuE2_WebUI-LICENSE \
|
||||||
|
&& python -m pip install --no-cache-dir -r /opt/yue2/webui/requirements.txt \
|
||||||
|
&& rm -rf /tmp/yue2-webui
|
||||||
|
|
||||||
|
# SheetSage2 converts an uploaded recording into the ABC score YuE2 consumes.
|
||||||
|
# Keep its pinned Transformers/Numpy stack in a small virtual environment, but
|
||||||
|
# reuse the image's Blackwell-capable torch 2.10 + CUDA 12.8 installation.
|
||||||
|
# The upstream torch 2.8+cu126 recipe cannot execute RTX 50-series kernels.
|
||||||
|
RUN python -m venv --system-site-packages /opt/yue2/.venv-sheetsage2 \
|
||||||
|
&& /opt/yue2/.venv-sheetsage2/bin/pip install --no-cache-dir \
|
||||||
|
"torchaudio==2.10.0" --index-url https://download.pytorch.org/whl/cu128 \
|
||||||
|
&& /opt/yue2/.venv-sheetsage2/bin/pip install --no-cache-dir \
|
||||||
|
"transformers==4.45.2" "huggingface-hub==0.36.0" \
|
||||||
|
"safetensors==0.5.3" "numpy==1.26.4" "scipy==1.13.1" \
|
||||||
|
"mir_eval==0.8.2" "pretty_midi==0.2.10" "mido==1.3.3" \
|
||||||
|
"setuptools==78.1.1"
|
||||||
|
|
||||||
|
COPY community-webui-patches /tmp/community-webui-patches
|
||||||
|
RUN python /tmp/community-webui-patches/patch_webui.py /opt/yue2/webui \
|
||||||
|
&& rm -rf /tmp/community-webui-patches
|
||||||
|
|
||||||
|
COPY ui /opt/yue2-playground
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
YUE2_KIT=/workspace
|
||||||
|
|
||||||
|
ENTRYPOINT ["yue2"]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# YuE2 3B isolated quality test
|
||||||
|
|
||||||
|
Prepared, non-starting evaluation of `m-a-p/YuE2-3B` with the standard
|
||||||
|
`m-a-p/YuE2-Vae` listening decoder. The source is pinned to the official
|
||||||
|
`yue2-v0.1.6` commit `9c6c4b349be978b06a9d0d958471a07a6cdeff4d`.
|
||||||
|
|
||||||
|
Preparation on Athena is complete. The model and VAE files were checked
|
||||||
|
against their published `weights_manifest.json` SHA-256 values. The Docker
|
||||||
|
image is built, but no YuE2 container has been created or started.
|
||||||
|
|
||||||
|
## Safety and isolation
|
||||||
|
|
||||||
|
- This experiment is not part of the profile controller or dashboard.
|
||||||
|
- The Compose service uses the `manual` profile, has no restart policy and
|
||||||
|
cannot start through an ordinary `docker compose up`.
|
||||||
|
- Only the RTX 5080 is exposed to the container.
|
||||||
|
- Building and downloading do not load the model or use a GPU.
|
||||||
|
- Do not start it while another Athena GPU job is active.
|
||||||
|
|
||||||
|
## Persistent files
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/models/yue2/
|
||||||
|
├── YuE2-3B/
|
||||||
|
└── YuE2-Vae/
|
||||||
|
|
||||||
|
/data/music/yue2/
|
||||||
|
```
|
||||||
|
|
||||||
|
The initial control request is a true empty-lyrics instrumental request. No
|
||||||
|
invented `[Instrumental]` lyrics marker is used.
|
||||||
|
|
||||||
|
## Community WebUI
|
||||||
|
|
||||||
|
The `community-ui` profile runs Ladypoly's YuE2 WebUI at the pinned commit
|
||||||
|
`8fc05609bde5dcd345d7c6d57fff3da0839164c5`. Only the WebUI layer is copied
|
||||||
|
from that repository. Athena continues to use the verified official YuE2
|
||||||
|
`0.1.6` runtime and the existing model cache; the WebUI fork's bundled model
|
||||||
|
code and Windows installers are not used.
|
||||||
|
|
||||||
|
The UI is bound only to Athena's localhost on port 8014 and stores complete
|
||||||
|
takes below `/data/music/yue2`. Optional Windows-only installers for llama.cpp
|
||||||
|
and stable-diffusion.cpp are not part of the Athena setup. Manual composition,
|
||||||
|
generation, result playback, score editing and the take library work without
|
||||||
|
those optional components.
|
||||||
|
|
||||||
|
### Uploaded-audio remix (SheetSage2)
|
||||||
|
|
||||||
|
The **Remix a take** drawer also accepts WAV, FLAC, MP3, M4A, OGG, Opus and
|
||||||
|
AAC uploads. SheetSage2 transcribes the recording into an editable ABC melody
|
||||||
|
and chord plan, then YuE2 renders that structure in a newly selected style.
|
||||||
|
It does not preserve the original samples, singer or production verbatim.
|
||||||
|
|
||||||
|
SheetSage2 is kept in `/opt/yue2/.venv-sheetsage2`, while its persistent model
|
||||||
|
files live below `/data/models/yue2`. The environment intentionally reuses the
|
||||||
|
image's PyTorch 2.10/CUDA 12.8 runtime: the upstream cu126 recipe is not
|
||||||
|
Blackwell-capable. Required persistent directories are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/models/yue2/
|
||||||
|
├── SheetSage2/
|
||||||
|
└── MERT-v2-FullSong/
|
||||||
|
```
|
||||||
|
|
||||||
|
The local `SheetSage2/config.json` must point `base_model_name_or_path` to
|
||||||
|
`/opt/yue2/models/MERT-v2-FullSong`, allowing the complete transcription path
|
||||||
|
to run offline. Analysis and generation share the RTX 5080 and therefore run
|
||||||
|
sequentially; the UI parks YuE2 before starting SheetSage2.
|
||||||
|
|
||||||
|
The small integration patch under `community-webui-patches/` fixes the
|
||||||
|
community release's missing `refreshArt()` function on Linux and permits the
|
||||||
|
native YuE2 empty-lyrics request for true instrumentals. It deliberately does
|
||||||
|
not modify the model runtime.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --profile community-ui up -d yue2-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
The original small German playground remains available as a stopped fallback
|
||||||
|
on localhost port 8016 through the `playground-fallback` profile. Do not run
|
||||||
|
both frontends concurrently because both can submit work to the same GPU.
|
||||||
|
|
||||||
|
## Manual test (only after GPU availability was checked)
|
||||||
|
|
||||||
|
From `/opt/mike-ai/yue2-3b` on Athena:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --profile manual run --rm yue2-test generate \
|
||||||
|
--offline \
|
||||||
|
--device cuda:0 \
|
||||||
|
--budget 16 \
|
||||||
|
--request /workspace/requests/instrumental-synthwave.json \
|
||||||
|
--output /workspace/runs
|
||||||
|
```
|
||||||
|
|
||||||
|
Start with the official unquantized BF16 path. If and only if this fails from
|
||||||
|
VRAM pressure, repeat with `--quantization fp8 --offload-ar`; keep the outputs
|
||||||
|
separate because that is a different inference configuration.
|
||||||
|
|
||||||
|
YuE2 is newly released and officially specifies a 24-GB BF16 GPU. Readiness of
|
||||||
|
this image and the downloaded weights is not evidence that the 16-GB RTX 5080
|
||||||
|
run will fit or that its audio quality is acceptable.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Small Linux integration fixes for the pinned community WebUI.
|
||||||
|
|
||||||
|
Keep these transformations explicit and fail the image build if upstream moves
|
||||||
|
the expected code. That prevents a future upstream update from silently
|
||||||
|
producing a half-patched console.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
root = Path(sys.argv[1])
|
||||||
|
app_js = root / "static" / "app.js"
|
||||||
|
index_html = root / "static" / "index.html"
|
||||||
|
server_py = root / "server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def replace_once(path: Path, old: str, new: str) -> None:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if text.count(old) != 1:
|
||||||
|
raise RuntimeError(f"expected exactly one patch marker in {path}: {old[:80]!r}")
|
||||||
|
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# The published UI calls refreshArt() during startup, but does not define it.
|
||||||
|
# Athena does not install the Windows-only stable-diffusion.cpp helper, so show
|
||||||
|
# that state honestly and keep the optional controls inert instead of crashing
|
||||||
|
# the whole page.
|
||||||
|
art_marker = ''' $("artDirAdd").addEventListener("click", function () {
|
||||||
|
'''
|
||||||
|
art_fix = ''' function refreshArt() {
|
||||||
|
return api("/api/art/status").then(function (data) {
|
||||||
|
STATE.art = data;
|
||||||
|
var installed = !!data.installed;
|
||||||
|
var supported = !!data.supported;
|
||||||
|
$("artState").textContent = data.busy || (installed ? "installed" :
|
||||||
|
(supported ? "not installed" : "not available on Linux"));
|
||||||
|
$("artState").dataset.s = data.busy ? "busy" : (installed ? "ready" : "missing");
|
||||||
|
$("artInstall").disabled = !!data.busy || !supported;
|
||||||
|
$("artInstall").textContent = installed ? "Reinstall" : "Install";
|
||||||
|
$("setArtAuto").checked = !!data.auto;
|
||||||
|
$("setArtAuto").disabled = !installed;
|
||||||
|
$("setArtModel").innerHTML = (data.models || []).length
|
||||||
|
? data.models.map(function (m) {
|
||||||
|
return '<option value="' + escape(m.path) + '">' + escape(m.name || m.file) + '</option>';
|
||||||
|
}).join("")
|
||||||
|
: '<option value="">no art model available</option>';
|
||||||
|
if (data.selected) $("setArtModel").value = data.selected;
|
||||||
|
$("setArtModel").disabled = !installed;
|
||||||
|
$("artDir").disabled = !supported;
|
||||||
|
$("artDirAdd").disabled = !supported;
|
||||||
|
$("artDirs").textContent = supported
|
||||||
|
? ((data.dirs || []).length ? "Also scanning: " + data.dirs.join(" · ") : "No extra folders configured.")
|
||||||
|
: "The community release only provides the cover-art installer for Windows; song generation is unaffected.";
|
||||||
|
$("artCatalog").innerHTML = "";
|
||||||
|
return data;
|
||||||
|
}).catch(function (error) {
|
||||||
|
$("artState").textContent = "unavailable";
|
||||||
|
$("artInstall").disabled = true;
|
||||||
|
$("artDirs").textContent = error.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$("artDirAdd").addEventListener("click", function () {
|
||||||
|
'''
|
||||||
|
replace_once(app_js, art_marker, art_fix)
|
||||||
|
|
||||||
|
# YuE2 natively supports an empty lyric string for true instrumentals. The
|
||||||
|
# community server rejected that valid request even though the browser did not.
|
||||||
|
replace_once(
|
||||||
|
server_py,
|
||||||
|
''' if not body.style.strip() or not body.lyrics.strip():
|
||||||
|
raise HTTPException(400, "A style prompt and lyrics are both required")
|
||||||
|
''',
|
||||||
|
''' if not body.style.strip():
|
||||||
|
raise HTTPException(400, "A style prompt is required")
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
|
||||||
|
replace_once(
|
||||||
|
index_html,
|
||||||
|
'''<span class="label">Lyrics <em>section tags on their own line</em></span>''',
|
||||||
|
'''<span class="label">Lyrics <em>section tags on their own line · leave empty for a true instrumental</em></span>''',
|
||||||
|
)
|
||||||
|
|
||||||
|
# The backend already ships a complete SheetSage2 upload endpoint but hides it
|
||||||
|
# from the published console. Surface it beside the existing take-to-take
|
||||||
|
# remix controls so uploaded songs can seed a new melody/arrangement.
|
||||||
|
replace_once(
|
||||||
|
index_html,
|
||||||
|
''' <p class="row-hint" id="coverStatus"></p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
''',
|
||||||
|
''' <p class="row-hint" id="coverStatus"></p>
|
||||||
|
|
||||||
|
<h4>Remix an uploaded recording</h4>
|
||||||
|
<p class="hint">SheetSage2 listens to an uploaded song and writes an editable melody
|
||||||
|
and chord score for YuE2. This is structural transcription, not a sample or a copy of
|
||||||
|
the original sound. After analysis, choose a new style and generate normally.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="field grow">
|
||||||
|
<span class="label">Source audio</span>
|
||||||
|
<input id="coverAudio" type="file" accept=".wav,.flac,.mp3,.m4a,.ogg,.opus,.aac,audio/*" />
|
||||||
|
</label>
|
||||||
|
<div class="field reset-cell">
|
||||||
|
<button type="button" class="btn ghost" id="coverFromAudio">Analyse uploaded melody</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input type="checkbox" id="coverAudioMelodyOnly" checked />
|
||||||
|
<span>Keep the melody, but let the new style rebuild the harmony</span></label>
|
||||||
|
<p class="row-hint" id="coverAudioStatus">Checking SheetSage2…</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
|
||||||
|
replace_once(
|
||||||
|
app_js,
|
||||||
|
''' $("coverStatus").textContent = takes.length
|
||||||
|
? takes.length + " takes carry a score you can remix."
|
||||||
|
: "Make a song in Full plan or Melody only mode first; Direct mode keeps no score.";
|
||||||
|
}).catch(function () {});
|
||||||
|
''',
|
||||||
|
''' $("coverStatus").textContent = takes.length
|
||||||
|
? takes.length + " takes carry a score you can remix."
|
||||||
|
: "Make a song in Full plan or Melody only mode first; Direct mode keeps no score.";
|
||||||
|
var sheetsage = data.sheetsage || {};
|
||||||
|
$("coverFromAudio").disabled = !sheetsage.available;
|
||||||
|
$("coverAudioStatus").textContent = sheetsage.available
|
||||||
|
? "SheetSage2 is ready. Analysis temporarily parks YuE2 because both use the GPU."
|
||||||
|
: "SheetSage2 is not installed yet; uploaded-audio remix is unavailable.";
|
||||||
|
}).catch(function (error) {
|
||||||
|
$("coverFromAudio").disabled = true;
|
||||||
|
$("coverAudioStatus").textContent = error.message;
|
||||||
|
});
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
|
||||||
|
replace_once(
|
||||||
|
app_js,
|
||||||
|
''' /* -------------------------------------------------------------- compose */
|
||||||
|
''',
|
||||||
|
''' $("coverFromAudio").addEventListener("click", function () {
|
||||||
|
var file = $("coverAudio").files[0];
|
||||||
|
if (!file) return toast("Choose an audio file first", "bad");
|
||||||
|
var button = $("coverFromAudio");
|
||||||
|
var body = new FormData();
|
||||||
|
body.append("file", file, file.name);
|
||||||
|
body.append("melody_only", $("coverAudioMelodyOnly").checked ? "true" : "false");
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = "Analysing…";
|
||||||
|
$("coverAudioStatus").textContent = "Listening to “" + file.name + "”… this can take several minutes.";
|
||||||
|
api("/api/cover/from-audio", { method: "POST", body: body }).then(function (data) {
|
||||||
|
applyCoverScore(data.abc, "Melody analysed from “" + data.source + "” in " + data.seconds + " s. Choose the new style and generate.");
|
||||||
|
$("coverAudioStatus").textContent = "Analysis complete in " + data.seconds + " s. The editable score is loaded below.";
|
||||||
|
}).catch(function (error) {
|
||||||
|
$("coverAudioStatus").textContent = error.message;
|
||||||
|
toast(error.message, "bad");
|
||||||
|
}).then(function () {
|
||||||
|
button.textContent = "Analyse uploaded melody";
|
||||||
|
refreshCover();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- compose */
|
||||||
|
''',
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
services:
|
||||||
|
yue2-test:
|
||||||
|
profiles: ["manual"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
YUE2_COMMIT: 9c6c4b349be978b06a9d0d958471a07a6cdeff4d
|
||||||
|
YUE2_WEBUI_COMMIT: 8fc05609bde5dcd345d7c6d57fff3da0839164c5
|
||||||
|
image: mike-ai/yue2:3b-0.1.6
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: "GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
volumes:
|
||||||
|
- /data/models/yue2:/workspace/models:ro
|
||||||
|
- /data/music/yue2:/workspace/runs
|
||||||
|
- ./requests:/workspace/requests:ro
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
yue2-ui:
|
||||||
|
profiles: ["community-ui"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
YUE2_COMMIT: 9c6c4b349be978b06a9d0d958471a07a6cdeff4d
|
||||||
|
YUE2_WEBUI_COMMIT: 8fc05609bde5dcd345d7c6d57fff3da0839164c5
|
||||||
|
image: mike-ai/yue2:3b-0.1.6
|
||||||
|
container_name: mike-ai-yue2-playground
|
||||||
|
restart: "no"
|
||||||
|
labels:
|
||||||
|
com.mike-ai.music-worker: yue2
|
||||||
|
entrypoint: ["python", "/opt/yue2/webui/server.py"]
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: "GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
YUE2_HOST: "0.0.0.0"
|
||||||
|
YUE2_PORT: "8014"
|
||||||
|
YUE2_OUTPUTS: "/workspace/runs"
|
||||||
|
HF_HUB_OFFLINE: "1"
|
||||||
|
TRANSFORMERS_OFFLINE: "1"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8014:8014"
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
aliases: [yue2-studio]
|
||||||
|
volumes:
|
||||||
|
- /data/models/yue2:/opt/yue2/models:ro
|
||||||
|
- /data/music/yue2:/workspace/runs
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8014/api/state', timeout=2)"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
# Kept as a deliberately non-default fallback until the community console
|
||||||
|
# has completed a real generation on Athena.
|
||||||
|
yue2-playground-fallback:
|
||||||
|
profiles: ["playground-fallback"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
YUE2_COMMIT: 9c6c4b349be978b06a9d0d958471a07a6cdeff4d
|
||||||
|
YUE2_WEBUI_COMMIT: 8fc05609bde5dcd345d7c6d57fff3da0839164c5
|
||||||
|
image: mike-ai/yue2:3b-0.1.6
|
||||||
|
container_name: mike-ai-yue2-playground-fallback
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["python", "/opt/yue2-playground/server.py"]
|
||||||
|
environment:
|
||||||
|
NVIDIA_VISIBLE_DEVICES: "GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
YUE2_UI_HOST: "0.0.0.0"
|
||||||
|
YUE2_UI_PORT: "8016"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8016:8016"
|
||||||
|
volumes:
|
||||||
|
- /data/models/yue2:/workspace/models:ro
|
||||||
|
- /data/music/yue2:/workspace/runs
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ["GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe"]
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend:
|
||||||
|
external: true
|
||||||
|
name: mike-ai_frontend
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "instrumental_synthwave_control",
|
||||||
|
"style": "Instrumental, synthwave, synth-pop, energetic, memorable lead melody, arpeggiated synthesizer, analog synthesizer, drum machine, driving bass, 118 BPM, no vocals",
|
||||||
|
"lyrics": "",
|
||||||
|
"cot": "full",
|
||||||
|
"seed": 831001
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>YuE2 Playground</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark;--bg:#090d14;--panel:#121925;--line:#293344;--text:#eef3fb;--muted:#98a6b9;--blue:#58a6ff;--green:#42d392;--red:#ff6b7a}
|
||||||
|
*{box-sizing:border-box} body{margin:0;background:radial-gradient(circle at 20% 0,#16243b 0,transparent 35%),var(--bg);font:15px/1.5 system-ui,sans-serif;color:var(--text)}
|
||||||
|
main{max-width:1100px;margin:auto;padding:36px 22px 80px} h1{font-size:36px;margin:0} h2{margin:0 0 16px}.lead{color:var(--muted);margin:4px 0 28px}
|
||||||
|
.grid{display:grid;grid-template-columns:1.15fr .85fr;gap:22px}@media(max-width:800px){.grid{grid-template-columns:1fr}}
|
||||||
|
.card{background:rgba(18,25,37,.94);border:1px solid var(--line);border-radius:16px;padding:22px;box-shadow:0 18px 60px #0005}
|
||||||
|
label{display:block;font-weight:650;margin:14px 0 6px}textarea,input,select{width:100%;background:#090e17;border:1px solid #344157;border-radius:9px;color:var(--text);padding:11px;font:inherit}textarea{resize:vertical;min-height:120px}
|
||||||
|
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.check{display:flex;align-items:center;gap:10px;margin:14px 0}.check input{width:auto}
|
||||||
|
button{border:0;border-radius:10px;padding:12px 16px;font-weight:750;cursor:pointer;background:var(--blue);color:#05101d}button:disabled{opacity:.45;cursor:not-allowed}.ghost{background:#242e3d;color:var(--text);padding:7px 10px}
|
||||||
|
.notice{padding:12px;border-radius:9px;background:#0c2630;color:#a9edda;margin-top:14px}.error{background:#371a23;color:#ffc0c7}.job{border-top:1px solid var(--line);padding:15px 0}.job:first-child{border-top:0;padding-top:0}.meta{color:var(--muted);font-size:13px}.state{font-weight:750;color:var(--green)}.state.failed{color:var(--red)}audio{width:100%;margin-top:10px}.job-head{display:flex;justify-content:space-between;gap:12px}.style{white-space:pre-wrap;margin:5px 0}.empty{color:var(--muted)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><main>
|
||||||
|
<h1>YuE2 Playground 🎵</h1><p class="lead">Lokale Musikgenerierung auf Athena · 48 kHz Stereo · RTX 5080</p>
|
||||||
|
<div class="grid">
|
||||||
|
<section class="card"><h2>Neuen Song erzeugen</h2>
|
||||||
|
<form id="form">
|
||||||
|
<label for="style">Stil und musikalische Vorgaben</label>
|
||||||
|
<textarea id="style" required>Instrumental, synthwave, synth-pop, energetic, memorable lead melody, arpeggiated synthesizer, analog synthesizer, drum machine, driving bass, 118 BPM, no vocals</textarea>
|
||||||
|
<label class="check"><input id="instrumental" type="checkbox" checked> Instrumental – ohne Gesang</label>
|
||||||
|
<div id="lyrics-wrap" hidden><label for="lyrics">Liedtext</label><textarea id="lyrics" placeholder="[Verse]\n...\n\n[Chorus]\n..."></textarea></div>
|
||||||
|
<div class="row"><div><label for="seed">Seed (leer = Zufall)</label><input id="seed" type="number" min="0" max="4294967295" placeholder="zufällig"></div>
|
||||||
|
<div><label for="cot">Kompositionsplanung</label><select id="cot"><option value="full">Vollständig – Melodie und Akkorde</option><option value="off">Direkt – ohne editierbaren Plan</option></select></div></div>
|
||||||
|
<div id="message" class="notice" hidden></div>
|
||||||
|
<button id="submit" type="submit" style="margin-top:18px;width:100%">Song generieren</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Ergebnisse</h2><div id="jobs" class="empty">Wird geladen …</div></section>
|
||||||
|
</div>
|
||||||
|
</main><script>
|
||||||
|
const $=s=>document.querySelector(s), form=$('#form'), inst=$('#instrumental'), lyricsWrap=$('#lyrics-wrap'), submit=$('#submit'), msg=$('#message'), jobs=$('#jobs');
|
||||||
|
inst.onchange=()=>lyricsWrap.hidden=inst.checked;
|
||||||
|
function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||||
|
function duration(v){if(!v)return '';const m=Math.floor(v/60),s=Math.round(v%60);return `${m}:${String(s).padStart(2,'0')} min`}
|
||||||
|
async function refresh(){try{const r=await fetch('/api/jobs',{cache:'no-store'}),d=await r.json();submit.disabled=!!d.active;submit.textContent=d.active?'YuE2 arbeitet …':'Song generieren';jobs.className='';jobs.innerHTML=d.jobs.length?d.jobs.map(j=>`<article class="job"><div class="job-head"><span class="state ${j.state}">${j.state==='running'?'Wird erzeugt …':j.state==='complete'?'Fertig':'Fehlgeschlagen'}</span>${j.state!=='running'?`<button class="ghost" onclick="removeJob('${esc(j.id)}')">Löschen</button>`:''}</div><div class="style">${esc(j.style)}</div><div class="meta">Seed ${esc(j.seed)} · ${j.instrumental?'Instrumental':'Gesang'}${j.audio_seconds?` · ${duration(j.audio_seconds)}`:''}${j.elapsed_seconds?` · erzeugt in ${duration(j.elapsed_seconds)}`:''}</div>${j.audio_url?`<audio controls preload="metadata" src="${j.audio_url}"></audio><p><a href="${j.audio_url}" download="${esc(j.id)}.flac">FLAC herunterladen</a></p>`:''}${j.error?`<div class="notice error">${esc(j.error)}<pre>${esc(j.log||'')}</pre></div>`:''}</article>`).join(''):'<p class="empty">Noch keine Songs vorhanden.</p>'}catch(e){jobs.innerHTML=`<div class="notice error">${esc(e)}</div>`}}
|
||||||
|
async function removeJob(id){if(!confirm('Diesen Song und alle Zwischenartefakte wirklich löschen?'))return;await fetch('/api/jobs/'+encodeURIComponent(id),{method:'DELETE'});refresh()}
|
||||||
|
form.onsubmit=async e=>{e.preventDefault();msg.hidden=true;submit.disabled=true;try{const payload={style:$('#style').value,lyrics:$('#lyrics').value,instrumental:inst.checked,seed:$('#seed').value,cot:$('#cot').value};const r=await fetch('/api/jobs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}),d=await r.json();if(!r.ok)throw new Error(d.error||'Start fehlgeschlagen');msg.className='notice';msg.textContent=`Auftrag ${d.id} gestartet. Die Seite aktualisiert sich automatisch.`;msg.hidden=false;refresh()}catch(e){msg.className='notice error';msg.textContent=e.message;msg.hidden=false;submit.disabled=false}};
|
||||||
|
refresh();setInterval(refresh,2000);
|
||||||
|
</script></body></html>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
HOST = os.getenv("YUE2_UI_HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.getenv("YUE2_UI_PORT", "8014"))
|
||||||
|
ROOT = Path("/workspace/runs")
|
||||||
|
REQUESTS = ROOT / ".playground_requests"
|
||||||
|
LOGS = ROOT / ".playground_logs"
|
||||||
|
INDEX = Path(__file__).with_name("index.html")
|
||||||
|
SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||||
|
LOCK = threading.Lock()
|
||||||
|
JOBS: dict[str, dict] = {}
|
||||||
|
ACTIVE: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def existing_jobs() -> list[dict]:
|
||||||
|
found: list[dict] = []
|
||||||
|
for result_file in ROOT.glob("*/result.json"):
|
||||||
|
try:
|
||||||
|
result = json.loads(result_file.read_text(encoding="utf-8"))
|
||||||
|
request_file = result_file.parent / "request.json"
|
||||||
|
request = json.loads(request_file.read_text(encoding="utf-8"))
|
||||||
|
found.append({
|
||||||
|
"id": result_file.parent.name,
|
||||||
|
"state": "complete",
|
||||||
|
"style": request.get("style", ""),
|
||||||
|
"seed": request.get("seed"),
|
||||||
|
"instrumental": not bool(request.get("lyrics")),
|
||||||
|
"audio_seconds": result.get("audio_seconds"),
|
||||||
|
"elapsed_seconds": (result.get("timing") or {}).get("e2e_seconds"),
|
||||||
|
"audio_url": f"/audio/{result_file.parent.name}",
|
||||||
|
"created": int(result_file.stat().st_mtime * 1000),
|
||||||
|
})
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return sorted(found, key=lambda item: item["created"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot() -> dict:
|
||||||
|
with LOCK:
|
||||||
|
live = [dict(item) for item in JOBS.values()]
|
||||||
|
active = ACTIVE
|
||||||
|
known = {item["id"] for item in live}
|
||||||
|
live.extend(item for item in existing_jobs() if item["id"] not in known)
|
||||||
|
return {"active": active, "jobs": sorted(live, key=lambda item: item["created"], reverse=True)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_job(job_id: str, request: dict) -> None:
|
||||||
|
global ACTIVE
|
||||||
|
output = ROOT / job_id
|
||||||
|
request_file = REQUESTS / f"{job_id}.json"
|
||||||
|
log_file = LOGS / f"{job_id}.log"
|
||||||
|
command = [
|
||||||
|
"yue2", "generate", "--offline", "--device", "cuda:0", "--budget", "16",
|
||||||
|
# YuE2 creates a child directory from request["id"] itself.
|
||||||
|
"--request", str(request_file), "--output", str(ROOT),
|
||||||
|
]
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
with log_file.open("w", encoding="utf-8") as log:
|
||||||
|
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, text=True)
|
||||||
|
with LOCK:
|
||||||
|
JOBS[job_id]["pid"] = process.pid
|
||||||
|
code = process.wait()
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f"YuE2 wurde mit Exit-Code {code} beendet")
|
||||||
|
result = json.loads((output / "result.json").read_text(encoding="utf-8"))
|
||||||
|
update = {
|
||||||
|
"state": "complete",
|
||||||
|
"audio_seconds": result.get("audio_seconds"),
|
||||||
|
"elapsed_seconds": round(time.monotonic() - started, 1),
|
||||||
|
"audio_url": f"/audio/{job_id}",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
tail = ""
|
||||||
|
try:
|
||||||
|
tail = "\n".join(log_file.read_text(encoding="utf-8", errors="replace").splitlines()[-30:])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
update = {"state": "failed", "error": str(exc), "log": tail,
|
||||||
|
"elapsed_seconds": round(time.monotonic() - started, 1)}
|
||||||
|
with LOCK:
|
||||||
|
JOBS[job_id].update(update)
|
||||||
|
ACTIVE = None
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "YuE2Playground/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: object) -> None:
|
||||||
|
print(f"{self.address_string()} - {fmt % args}", flush=True)
|
||||||
|
|
||||||
|
def json_response(self, status: int, payload: object) -> None:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/health":
|
||||||
|
self.json_response(200, {"status": "ok", "active": ACTIVE})
|
||||||
|
return
|
||||||
|
if path == "/api/jobs":
|
||||||
|
self.json_response(200, snapshot())
|
||||||
|
return
|
||||||
|
if path.startswith("/audio/"):
|
||||||
|
self.send_audio(unquote(path.removeprefix("/audio/")))
|
||||||
|
return
|
||||||
|
if path in {"/", "/index.html"}:
|
||||||
|
body = INDEX.read_bytes()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
global ACTIVE
|
||||||
|
if urlparse(self.path).path != "/api/jobs":
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length <= 0 or length > 65536:
|
||||||
|
raise ValueError("Ungültige Anfragegröße")
|
||||||
|
data = json.loads(self.rfile.read(length))
|
||||||
|
style = str(data.get("style", "")).strip()
|
||||||
|
lyrics = str(data.get("lyrics", "")).strip()
|
||||||
|
instrumental = bool(data.get("instrumental", False))
|
||||||
|
cot = str(data.get("cot", "full"))
|
||||||
|
if not style or len(style) > 3000:
|
||||||
|
raise ValueError("Bitte eine Stilbeschreibung mit höchstens 3000 Zeichen eingeben")
|
||||||
|
if len(lyrics) > 20000:
|
||||||
|
raise ValueError("Der Liedtext ist zu lang")
|
||||||
|
if cot not in {"full", "off"}:
|
||||||
|
raise ValueError("Unbekannter Planungsmodus")
|
||||||
|
if not instrumental and not lyrics:
|
||||||
|
raise ValueError("Für einen Song mit Gesang fehlt der Liedtext")
|
||||||
|
if instrumental:
|
||||||
|
lyrics = ""
|
||||||
|
if "instrumental" not in style.casefold():
|
||||||
|
style = "Instrumental, no vocals, " + style
|
||||||
|
raw_seed = data.get("seed")
|
||||||
|
seed = int(raw_seed) if str(raw_seed).strip() else random.SystemRandom().randrange(1, 2**31)
|
||||||
|
if not 0 <= seed < 2**32:
|
||||||
|
raise ValueError("Seed muss zwischen 0 und 4294967295 liegen")
|
||||||
|
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
self.json_response(400, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
|
||||||
|
with LOCK:
|
||||||
|
if ACTIVE is not None:
|
||||||
|
self.json_response(409, {"error": f"Auftrag {ACTIVE} läuft bereits"})
|
||||||
|
return
|
||||||
|
job_id = time.strftime("song-%Y%m%d-%H%M%S") + f"-{seed % 10000:04d}"
|
||||||
|
request = {"id": job_id, "style": style, "lyrics": lyrics, "cot": cot, "seed": seed}
|
||||||
|
REQUESTS.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOGS.mkdir(parents=True, exist_ok=True)
|
||||||
|
request_file = REQUESTS / f"{job_id}.json"
|
||||||
|
request_file.write_text(json.dumps(request, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
JOBS[job_id] = {"id": job_id, "state": "running", "style": style,
|
||||||
|
"seed": seed, "instrumental": instrumental,
|
||||||
|
"created": now_ms(), "elapsed_seconds": 0}
|
||||||
|
ACTIVE = job_id
|
||||||
|
threading.Thread(target=run_job, args=(job_id, request), daemon=True).start()
|
||||||
|
self.json_response(HTTPStatus.ACCEPTED, JOBS[job_id])
|
||||||
|
|
||||||
|
def do_DELETE(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
job_id = unquote(path.removeprefix("/api/jobs/"))
|
||||||
|
if not path.startswith("/api/jobs/") or not SAFE_ID.fullmatch(job_id):
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
with LOCK:
|
||||||
|
if ACTIVE == job_id:
|
||||||
|
self.json_response(409, {"error": "Ein laufender Auftrag kann nicht gelöscht werden"})
|
||||||
|
return
|
||||||
|
JOBS.pop(job_id, None)
|
||||||
|
shutil.rmtree(ROOT / job_id, ignore_errors=True)
|
||||||
|
for directory, suffix in ((REQUESTS, ".json"), (LOGS, ".log")):
|
||||||
|
try:
|
||||||
|
(directory / f"{job_id}{suffix}").unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
self.json_response(200, {"status": "deleted", "id": job_id})
|
||||||
|
|
||||||
|
def send_audio(self, job_id: str) -> None:
|
||||||
|
if not SAFE_ID.fullmatch(job_id):
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
path = ROOT / job_id / "audio.flac"
|
||||||
|
if not path.is_file():
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
size = path.stat().st_size
|
||||||
|
start, end = 0, size - 1
|
||||||
|
status = 200
|
||||||
|
range_header = self.headers.get("Range", "")
|
||||||
|
if range_header.startswith("bytes="):
|
||||||
|
try:
|
||||||
|
left, right = range_header[6:].split("-", 1)
|
||||||
|
start = int(left) if left else 0
|
||||||
|
end = min(int(right), size - 1) if right else size - 1
|
||||||
|
if start < 0 or start > end:
|
||||||
|
raise ValueError
|
||||||
|
status = 206
|
||||||
|
except ValueError:
|
||||||
|
self.send_error(416)
|
||||||
|
return
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", mimetypes.guess_type(path.name)[0] or "audio/flac")
|
||||||
|
self.send_header("Accept-Ranges", "bytes")
|
||||||
|
self.send_header("Content-Length", str(end - start + 1))
|
||||||
|
if status == 206:
|
||||||
|
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||||
|
self.end_headers()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
source.seek(start)
|
||||||
|
remaining = end - start + 1
|
||||||
|
while remaining:
|
||||||
|
chunk = source.read(min(1024 * 1024, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
# Browsers routinely close an old range request after a
|
||||||
|
# seek or metadata probe. This is not a server failure.
|
||||||
|
break
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"YuE2 Playground listening on {HOST}:{PORT}", flush=True)
|
||||||
|
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# Athena: verbindlicher Kontext für KI-Agenten
|
||||||
|
|
||||||
|
Stand: 10. September 2026, nach Integration von TRELLIS.2 als 3D-Studio.
|
||||||
|
|
||||||
|
Diese Datei ist die erste Lektüre für jede KI, die Athena prüfen oder ändern
|
||||||
|
soll. Sie beschreibt den realen Aufbau, die Zuständigkeiten und die Regeln für
|
||||||
|
sichere Erweiterungen. Bei Abweichungen zwischen Annahmen und Live-System gilt:
|
||||||
|
erst lesend prüfen, dann die Dokumentation und den Code gemeinsam korrigieren.
|
||||||
|
|
||||||
|
## Unverhandelbare Sicherheitsregeln
|
||||||
|
|
||||||
|
1. **Athena niemals herunterfahren oder neu starten.** Der Rechner steht in
|
||||||
|
einer anderen Stadt und ist nicht kurzfristig physisch erreichbar.
|
||||||
|
2. Ohne ausdrücklichen aktuellen Auftrag weder Kernel, Bootloader, BIOS,
|
||||||
|
Partitionen, Mounts, SSH, LAN, WireGuard noch Firewall verändern.
|
||||||
|
3. Secrets dürfen lokal benutzt, aber niemals ausgegeben, geloggt oder in Git
|
||||||
|
aufgenommen werden. Das betrifft besonders `/etc/mike-ai`.
|
||||||
|
4. Keine laufende Modellarbeit abbrechen. Vor Änderungen Betriebsmodus,
|
||||||
|
Containerzustand und GPU-Prozesse prüfen.
|
||||||
|
5. Keine pauschalen Docker-Bereinigungen ausführen. Ein gestoppter Worker ist
|
||||||
|
meistens gewollt und kein Müll.
|
||||||
|
6. Keine Container anhand zufälliger IDs verdrahten. Stabile Dienstnamen,
|
||||||
|
Compose-Netze und eindeutige `com.mike-ai.*`-Labels verwenden.
|
||||||
|
7. Änderungen klein und reversibel halten. Nie den gesamten Stack neu erstellen,
|
||||||
|
wenn ein einzelner Dienst aktualisiert werden kann.
|
||||||
|
|
||||||
|
## Physischer und logischer Aufbau
|
||||||
|
|
||||||
|
```text
|
||||||
|
Athena: ASUS PRIME B550-PLUS
|
||||||
|
├── Debian 13 (trixie), Kernel 6.12
|
||||||
|
├── AMD Ryzen 5 5600, 6 Kerne / 12 Threads
|
||||||
|
├── 46 GiB nutzbarer RAM + 47 GiB Swap
|
||||||
|
├── System: Samsung 980 PRO 1 TB, ext4 auf /
|
||||||
|
├── Daten: WD Blue SN580 1 TB, ext4 auf /data
|
||||||
|
├── GPU 0: RTX 3060, 12.288 MiB
|
||||||
|
├── GPU 1: RTX 5080, 16.303 MiB
|
||||||
|
└── Docker
|
||||||
|
├── Kernprojekt /opt/mike-ai/stack
|
||||||
|
│ ├── Router, Profile Controller und Dashboard
|
||||||
|
│ ├── fünf llama.cpp-Profile
|
||||||
|
│ ├── Bild, Qwen3-TTS, TTS-Gateway und Whisper
|
||||||
|
│ ├── WireGuard-Gateway, Portainer, Backup
|
||||||
|
│ └── Athena-Operator
|
||||||
|
├── /opt/mike-ai/acestep-test Musik
|
||||||
|
├── /opt/mike-ai/stem-separator Audio-Trennung
|
||||||
|
├── /opt/mike-ai/omnivoice-studio Voice Studio
|
||||||
|
├── /opt/mike-ai/xvc-studio Voice Changer
|
||||||
|
├── /opt/mike-ai/stack/experiments/applio-rvc
|
||||||
|
│ Applio/RVC
|
||||||
|
├── /opt/mike-ai/Mikes-Applio-UI geführte Applio-UI
|
||||||
|
└── /opt/mike-ai/trellis-studio 3D Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
Die beiden GPUs bilden **keinen gemeinsamen VRAM-Pool**. Ein Backend muss
|
||||||
|
Mehrkartenbetrieb ausdrücklich unterstützen. Die Nummern oben sind Hostnummern;
|
||||||
|
wenn ein Container nur `NVIDIA_VISIBLE_DEVICES=1` erhält, sieht er die RTX 5080
|
||||||
|
innerhalb des Containers üblicherweise als GPU 0.
|
||||||
|
|
||||||
|
## Rollen der dauerhaften Kerndienste
|
||||||
|
|
||||||
|
| Dienst | Rolle |
|
||||||
|
|---|---|
|
||||||
|
| `mike-ai-router` | Einzige OpenAI-kompatible Modelladresse; besitzt die Zustandsmaschine für Profile und Betriebsmodi. |
|
||||||
|
| `mike-ai-profile-controller` | Darf ausschließlich freigegebene, eindeutig markierte Worker starten und stoppen. |
|
||||||
|
| `mike-ai-llama-dashboard` | Telemetrie, Modusumschaltung und Download der portablen Backups. |
|
||||||
|
| `mike-ai-wireguard-gateway` | Veröffentlicht interne Dienste an der privaten Adresse `192.168.1.212`; keine öffentliche/LAN-Bindung. |
|
||||||
|
| `mike-ai-tts-gateway` | Stabile TTS-API, Textnormalisierung, Formatumwandlung und PCM-Streaming; enthält kein Ersatzmodell. |
|
||||||
|
| `mike-ai-whisper` | Dauerhafte CPU-Spracherkennung mit Whisper.cpp `ggml-small`. |
|
||||||
|
| `mike-ai-mcp-athena-operator` | Begrenzte Verwaltungsfunktionen für Agenten; kein allgemeiner Root-Ersatz. |
|
||||||
|
| `mike-ai-backup` | Lokales Schnellbackup; externe Disaster-Sicherung läuft zusätzlich über systemd-Timer. |
|
||||||
|
|
||||||
|
## LLM-Profile
|
||||||
|
|
||||||
|
Es läuft höchstens ein llama.cpp-Profil. Die Standardprofile nutzen
|
||||||
|
Qwen3.8-27B in Q4-Quantisierung.
|
||||||
|
|
||||||
|
| Profil | API-Name | Kontext | Vision |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| Fast | `qwen-fast` | 76.800 | ja |
|
||||||
|
| Medium | `qwen-medium` | 160.000 | ja |
|
||||||
|
| Large | `qwen-large` | 192.000 | ja |
|
||||||
|
| Ultra | `qwen-ultra` | 262.144 | nein |
|
||||||
|
| Uncensored | `qwen-uncensored` | 80.000 | ja, eigener Projektor |
|
||||||
|
|
||||||
|
Die verbindlichen Parameter stehen in `config/profile-matrix.json`,
|
||||||
|
`router/router_profiles.json`, `platform/profiles/` und
|
||||||
|
`docs/STANDARD_PROFILE_MATRIX.md`. Diese Quellen dürfen sich nicht
|
||||||
|
widersprechen.
|
||||||
|
|
||||||
|
## Exklusive Betriebsmodi
|
||||||
|
|
||||||
|
Große GPU-Worker sind gegenseitig exklusiv. Der Router speichert
|
||||||
|
`mode`, `last_profile` und `return_profile` persistent. Beim Wechsel in einen
|
||||||
|
Spezialmodus werden LLM, Bildworker und Qwen3-TTS soweit nötig gestoppt; beim
|
||||||
|
Wechsel zu `llm` wird das zuvor gemerkte Profil wiederhergestellt.
|
||||||
|
|
||||||
|
| Modus | Worker / Modell | GPU-Nutzung | Oberfläche |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `llm` | ein Qwen-Profil + Qwen3-TTS | profilabhängig beide GPUs; TTS RTX 3060 | Router `:8081` |
|
||||||
|
| Bildauftrag | FLUX.2 Klein 9B FP8 + Qwen3-8B NF4 | RTX 5080 + RTX 3060, transaktional | über Router |
|
||||||
|
| `music` | ACE-Step 1.5 XL-SFT | RTX 5080 | `:7862` original, `:7861` Community |
|
||||||
|
| `yue2` | YuE2-3B + Ladypoly `YuE2_WebUI` | RTX 5080 | `:8014` |
|
||||||
|
| `separation` | BS-RoFormer, Demucs, MossFormer2 | RTX 5080 | `:8007` |
|
||||||
|
| `voice` | OmniVoice | RTX 5080 | `:8008` |
|
||||||
|
| `voicechange` | X-VC + optional Resemble Enhance | RTX 5080 | `:8009` |
|
||||||
|
| `applio` | Applio/RVC | RTX 5080 | `:8011`, eigene UI `:8012` |
|
||||||
|
| `trellis` | TRELLIS.2 4B Q8 über trellis.cpp 0.6.0 | ausschließlich RTX 5080 | `:8013` |
|
||||||
|
|
||||||
|
YuE2 liegt unter `/opt/mike-ai/yue2-3b`, seine Gewichte unter
|
||||||
|
`/data/models/yue2` und Ergebnisse unter `/data/music/yue2`. Der Container
|
||||||
|
trägt `com.mike-ai.music-worker=yue2` und muss mit dem Alias `yue2-studio` am
|
||||||
|
externen Netz `mike-ai_frontend` hängen. YuE2 niemals außerhalb der
|
||||||
|
Router-Zustandsmaschine dauerhaft starten: Sonst bleibt sein VRAM belegt und
|
||||||
|
der nächste LLM- oder Separator-Start kann mit OOM scheitern.
|
||||||
|
|
||||||
|
TRELLIS liegt unter `/opt/mike-ai/trellis-studio`. Seine Q8-Gewichte liegen
|
||||||
|
unter `/data/models/trellis2-q8`, die Runtime und Ausgaben unter
|
||||||
|
`/data/trellis-studio`. Die Oberfläche liefert GLB. `1024 · cascade` ist der
|
||||||
|
Qualitätsstandard für die 16-GiB-RTX-5080; 1536 kann den VRAM überschreiten.
|
||||||
|
Ein 512er Ende-zu-Ende-Test erzeugte am 10.09.2026 in 54,2 Sekunden ein
|
||||||
|
gültiges 4,4-MB-GLB.
|
||||||
|
|
||||||
|
## Steuerbefehle und Status
|
||||||
|
|
||||||
|
Im Dashboard wird über die Modus-API geschaltet. Hermes kann dieselbe
|
||||||
|
Zustandsmaschine mit exakten Befehlen bedienen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/athena music
|
||||||
|
/athena stems
|
||||||
|
/athena voice
|
||||||
|
/athena voicechange
|
||||||
|
/athena applio
|
||||||
|
/athena 3d
|
||||||
|
/athena trellis
|
||||||
|
/athena llm
|
||||||
|
/athena status
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Moduswechsel ist asynchron. Eine angenommene Anfrage bedeutet noch nicht,
|
||||||
|
dass der Worker bereit ist. Immer warten, bis `GET /status` beziehungsweise das
|
||||||
|
Dashboard `phase: ready`, den richtigen `active`-Modus und einen gesunden
|
||||||
|
Worker meldet. Bei Fehlern nicht blind erneut starten, sondern `last_error`,
|
||||||
|
Containerstatus und Logs lesen.
|
||||||
|
|
||||||
|
## Netzwerkmodell
|
||||||
|
|
||||||
|
Anwendungscontainer veröffentlichen ihre Host-Ports nur auf `127.0.0.1` oder
|
||||||
|
gar nicht. Das WireGuard-Gateway sitzt im externen Docker-Netz
|
||||||
|
`mike-ai_frontend`, bindet die private WireGuard-Adresse `192.168.1.212` und
|
||||||
|
leitet mit `socat` auf Compose-Dienstnamen weiter.
|
||||||
|
|
||||||
|
Wichtige Regeln:
|
||||||
|
|
||||||
|
- Gateway und Anwendung **nicht** über `network_mode: container:...` koppeln.
|
||||||
|
- Ziel ist zum Beispiel `trellis-studio:8080`, niemals eine Container-IP.
|
||||||
|
- Der Zielcontainer muss im selben externen Frontend-Netz liegen.
|
||||||
|
- Beim Hinzufügen eines Ports den Proxy-Eintrag im Gateway, das Dashboard und
|
||||||
|
die Endpunkt-Dokumentation gemeinsam ergänzen.
|
||||||
|
- Ein Gateway-Recreate kann eine bestehende SSH-Verbindung unterbrechen. Nur
|
||||||
|
kontrolliert und mit automatisch verzögertem Wiederanlauf durchführen.
|
||||||
|
- Nach einem Recreate DNS-Auflösung, Listener, Ziel-Healthcheck und Zugriff
|
||||||
|
über den WireGuard-Pfad prüfen.
|
||||||
|
|
||||||
|
## Daten und Sicherung
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `/opt/mike-ai` | Deployments, Compose-Projekte und lokale Quellstände |
|
||||||
|
| `/etc/mike-ai` | Konfiguration, Schlüssel und Tokens; geheim |
|
||||||
|
| `/data/models` | erneut ladbare Modellgewichte und Caches |
|
||||||
|
| `/data/voice` | Trainingsdaten, Checkpoints und trainierte Stimmen |
|
||||||
|
| `/data/music` | Musikprojekte und Ausgaben |
|
||||||
|
| `/data/audio` | Audio-Trennungen |
|
||||||
|
| `/data/trellis-studio` | trellis.cpp-Runtime und 3D-Ausgaben |
|
||||||
|
| `/data/llama-dashboard` | Telemetriehistorie |
|
||||||
|
| `/data/docker-backups` | lokale Schnellbackups |
|
||||||
|
|
||||||
|
Docker-Volumes: `mike-ai_router-state`, `mike-ai_router-images`,
|
||||||
|
`mike-ai_whisper-data`, `portainer_data`.
|
||||||
|
|
||||||
|
Das lokale Exportbackup läuft etwa alle fünf Stunden, das verschlüsselte
|
||||||
|
Disaster-Backup nachts. Ein Backup auf `/data` schützt nicht vor dem Ausfall
|
||||||
|
der Datenplatte. Details und alle drei Ausfallszenarien stehen in
|
||||||
|
`docs/RECOVERY.md`. **Aktuelle Lücke:** `/data/trellis-studio/output` ist im
|
||||||
|
ausgerollten Export- und Disaster-Backup noch nicht enthalten. Wichtige GLB-
|
||||||
|
Ausgaben daher zusätzlich extern sichern, bis die Backup-Skripte erweitert und
|
||||||
|
getestet wurden.
|
||||||
|
|
||||||
|
## Neuen GPU-Dienst korrekt hinzufügen
|
||||||
|
|
||||||
|
1. `docs/TESTED_MODELS.md` vollständig prüfen, damit kein verworfener Kandidat
|
||||||
|
erneut geladen wird.
|
||||||
|
2. Lizenz, Modellrevision, Runtime-Revision, VRAM, RAM, Ausgabeformat und
|
||||||
|
Hardwareunterstützung dokumentieren.
|
||||||
|
3. Eigenes Compose-Projekt oder klar abgegrenzten Kernservice anlegen. Image
|
||||||
|
und Upstream-Commit pinnen; nicht dauerhaft `latest` als einzige
|
||||||
|
Wiederherstellungsinformation verwenden.
|
||||||
|
4. Gewichte unter einem eindeutigen Verzeichnis in `/data/models` speichern,
|
||||||
|
veränderliche Ergebnisse separat unter `/data`.
|
||||||
|
5. `restart: "no"` für exklusive GPU-Worker verwenden. Dauerhafte UIs dürfen
|
||||||
|
laufen, dürfen aber im Leerlauf kein großes Modell laden.
|
||||||
|
6. Genau ein eindeutiges Label vergeben, zum Beispiel
|
||||||
|
`com.mike-ai.trellis-worker=trellis2-q8`. Der Controller muss bei null oder
|
||||||
|
mehreren Treffern absichtlich abbrechen.
|
||||||
|
7. Worker in **Controller, Router, Dashboard, Compose-Umgebung,
|
||||||
|
WireGuard-Proxy, Tests und Dokumentation** ergänzen.
|
||||||
|
8. Alle anderen exklusiven Worker sowohl beim Eintritt als auch beim Verlassen
|
||||||
|
des neuen Modus behandeln. Den Rückweg zum gespeicherten LLM-Profil testen.
|
||||||
|
9. Healthcheck-Werkzeuge tatsächlich im Image installieren. Ein Backendprozess
|
||||||
|
kann laufen, während ein fehlerhafter Healthcheck den Modus blockiert.
|
||||||
|
10. Bei Web-UIs korrekte MIME-Typen ausliefern. ES-Module benötigen
|
||||||
|
`application/javascript`, CSS `text/css`; Browsermodus muss denselben
|
||||||
|
Ursprung oder eine sauber konfigurierte API-Adresse verwenden.
|
||||||
|
11. Compose validieren, Syntax prüfen, nur den betroffenen Dienst bauen und
|
||||||
|
einen echten Ende-zu-Ende-Auftrag ausführen. Danach Rückschaltung testen.
|
||||||
|
12. Quellcode, Installer, Wiederaufbau und Dokumentation im selben Git-Stand
|
||||||
|
versionieren. Erst dann ist die Erweiterung wiederherstellbar.
|
||||||
|
|
||||||
|
## Dienst vollständig entfernen
|
||||||
|
|
||||||
|
1. Belegen, dass der Dienst nicht aktiv ist und keine laufende Arbeit besitzt.
|
||||||
|
2. Testergebnis und Ablehnungsgrund zuerst in `docs/TESTED_MODELS.md` sichern.
|
||||||
|
3. Routerbefehle, Zustandsfelder, Controller-Labelsuche, Dashboard-Schalter,
|
||||||
|
Proxy-Port, Compose-Projekt, Tests und Dokumentation entfernen.
|
||||||
|
4. Container und Image gezielt anhand exakter Namen entfernen.
|
||||||
|
5. Gewichte, Cache, Ausgaben und Volumes einzeln klassifizieren: reproduzierbar,
|
||||||
|
ersetzbar oder unersetzlich. Unersetzliche Daten sichern; keine Globs oder
|
||||||
|
pauschalen Prune-Befehle benutzen.
|
||||||
|
6. Prüfen, dass kein Labelduplikat, verwaister Proxy, unbenutztes Netz oder
|
||||||
|
verwaistes Volume übrig ist.
|
||||||
|
7. LLM-Modus wiederherstellen und einen Smoke-Test ausführen.
|
||||||
|
|
||||||
|
## Häufige Fehlerbilder
|
||||||
|
|
||||||
|
- **Controller meldet zwei Worker:** Während `docker compose up
|
||||||
|
--force-recreate` können alter und neuer Container kurz dasselbe Label
|
||||||
|
tragen. Recreate beenden lassen, danach exakt gelabelte Container prüfen und
|
||||||
|
erst dann den Modus erneut anfordern.
|
||||||
|
- **Webseite ist unformatiert und bleibt auf „connecting“:** MIME-Typen oder
|
||||||
|
Asset-Cache prüfen; nicht automatisch das KI-Backend beschuldigen.
|
||||||
|
- **Dashboard oder Port fehlt nach Recreate:** Listener im Gateway,
|
||||||
|
DNS-Auflösung des Dienstnamens und gemeinsames Frontend-Netz prüfen.
|
||||||
|
- **Worker gesund, Modus trotzdem fehlerhaft:** Routerzustand und
|
||||||
|
`last_error` können noch den vorherigen fehlgeschlagenen Übergang zeigen;
|
||||||
|
nach Beseitigung der Ursache Modus kontrolliert erneut anfordern.
|
||||||
|
- **VRAM scheinbar leer:** Manche Runtime lädt Gewichte erst beim ersten
|
||||||
|
Auftrag und gibt Speicher anschließend wieder frei. Ein Healthcheck allein
|
||||||
|
ist daher kein vollständiger GPU-Test.
|
||||||
|
- **Compose verwendet falsche Werte:** Der Kernstack benötigt
|
||||||
|
`--env-file /etc/mike-ai/stack.env`.
|
||||||
|
|
||||||
|
## Definition von „fertig“
|
||||||
|
|
||||||
|
Eine Änderung ist erst fertig, wenn sie im kanonischen Git-Stand liegt,
|
||||||
|
reproduzierbar gebaut werden kann, Compose/Syntax valide sind, der Dienst gesund
|
||||||
|
ist, ein echter kleiner Funktionsauftrag erfolgreich war, die Rückschaltung
|
||||||
|
funktioniert, Backup und WireGuard-Zugriff gesund geblieben sind und Commit
|
||||||
|
sowie Push erfolgt sind.
|
||||||
|
|
||||||
|
Weiterführend: `ATHENA.md`, `docs/ARCHITECTURE.md`,
|
||||||
|
`docs/OPERATING_MODES.md`, `docs/CONTAINER_INVENTORY.md`,
|
||||||
|
`docs/TESTED_MODELS.md` und `docs/RECOVERY.md`.
|
||||||
+96
-23
@@ -37,12 +37,18 @@ if (( (8#$config_mode & 077) != 0 )); then
|
|||||||
fi
|
fi
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
source "$CONFIG"
|
source "$CONFIG"
|
||||||
|
if [[ -n ${HF_TOKEN_FILE:-} && ! -r ${HF_TOKEN_FILE:-} && \
|
||||||
|
-r /etc/mike-ai/huggingface-token ]]; then
|
||||||
|
HF_TOKEN_FILE=/etc/mike-ai/huggingface-token
|
||||||
|
fi
|
||||||
|
|
||||||
required=(AI_HOSTNAME ADMIN_USER MODEL_DIR FAST_MODEL_FILE
|
required=(AI_HOSTNAME ADMIN_USER MODEL_DIR FAST_MODEL_FILE
|
||||||
FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL
|
FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL
|
||||||
MEDIUM_MODEL_SHA256 LARGE_MODEL_FILE LARGE_MODEL_URL LARGE_MODEL_SHA256
|
MEDIUM_MODEL_SHA256 LARGE_MODEL_FILE LARGE_MODEL_URL LARGE_MODEL_SHA256
|
||||||
ULTRA_MODEL_FILE ULTRA_MODEL_URL ULTRA_MODEL_SHA256
|
ULTRA_MODEL_FILE ULTRA_MODEL_URL ULTRA_MODEL_SHA256
|
||||||
EXPERIMENTAL_MODEL_FILE EXPERIMENTAL_MODEL_URL EXPERIMENTAL_MODEL_SHA256
|
UNCENSORED_MODEL_FILE UNCENSORED_MODEL_URL UNCENSORED_MODEL_SHA256
|
||||||
|
UNCENSORED_PROJECTOR_FILE UNCENSORED_PROJECTOR_URL
|
||||||
|
UNCENSORED_PROJECTOR_SHA256
|
||||||
VISION_PROJECTOR_FILE VISION_PROJECTOR_URL VISION_PROJECTOR_SHA256)
|
VISION_PROJECTOR_FILE VISION_PROJECTOR_URL VISION_PROJECTOR_SHA256)
|
||||||
for name in "${required[@]}"; do
|
for name in "${required[@]}"; do
|
||||||
[[ -n "${!name:-}" ]] || die "Pflichtwert $name fehlt."
|
[[ -n "${!name:-}" ]] || die "Pflichtwert $name fehlt."
|
||||||
@@ -67,7 +73,7 @@ install_base_packages() {
|
|||||||
apt-get update
|
apt-get update
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl git gnupg jq openssl wireguard-tools iptables \
|
ca-certificates curl git gnupg jq openssl wireguard-tools iptables \
|
||||||
iproute2 pciutils rsync unattended-upgrades ethtool age
|
iproute2 pciutils rsync unattended-upgrades ethtool age restic zstd
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_stable_network_name() {
|
setup_stable_network_name() {
|
||||||
@@ -283,8 +289,10 @@ setup_wireguard() {
|
|||||||
|
|
||||||
install_stack_files() {
|
install_stack_files() {
|
||||||
log "Stackdateien installieren"
|
log "Stackdateien installieren"
|
||||||
XTTS_CACHE_DIR=${XTTS_CACHE_DIR:-$MODEL_DIR/xtts-v2-cache}
|
QWEN3_TTS_CACHE_DIR=${QWEN3_TTS_CACHE_DIR:-$MODEL_DIR/qwen3-tts-cache}
|
||||||
install -d -m 0755 "$STACK_DIR" "$MODEL_DIR" "$XTTS_CACHE_DIR" "$STATE_DIR/backups"
|
QWEN3_TTS_VOICES_DIR=${QWEN3_TTS_VOICES_DIR:-$MODEL_DIR/qwen3-tts-voices}
|
||||||
|
install -d -m 0755 "$STACK_DIR" "$MODEL_DIR" "$QWEN3_TTS_CACHE_DIR" \
|
||||||
|
"$QWEN3_TTS_VOICES_DIR" "$STATE_DIR/backups"
|
||||||
# /opt/mike-ai/stack is both the live stack and the one canonical Git
|
# /opt/mike-ai/stack is both the live stack and the one canonical Git
|
||||||
# checkout. Copying everything except .git created two competing source
|
# checkout. Copying everything except .git created two competing source
|
||||||
# trees and made agents reconstruct deployment state on every change.
|
# trees and made agents reconstruct deployment state on every change.
|
||||||
@@ -299,6 +307,19 @@ install_stack_files() {
|
|||||||
printf '%s\n' "$source_head" >"$STACK_DIR/.mike-ai-source-commit"
|
printf '%s\n' "$source_head" >"$STACK_DIR/.mike-ai-source-commit"
|
||||||
fi
|
fi
|
||||||
install -d -m 0700 "$SECRETS_DIR"
|
install -d -m 0700 "$SECRETS_DIR"
|
||||||
|
# Keep the exact host bootstrap inputs with the protected system
|
||||||
|
# configuration. This breaks the former recovery cycle in which a fresh
|
||||||
|
# host needed a lost /root-only install file before it could restore backup.
|
||||||
|
if [[ $(realpath "$CONFIG") != $(realpath -m "$SECRETS_DIR/install.env") ]]; then
|
||||||
|
install -m 0600 "$CONFIG" "$SECRETS_DIR/install.env"
|
||||||
|
else
|
||||||
|
chmod 0600 "$SECRETS_DIR/install.env"
|
||||||
|
fi
|
||||||
|
if [[ -n ${HF_TOKEN_FILE:-} && -r $HF_TOKEN_FILE && \
|
||||||
|
$(realpath "$HF_TOKEN_FILE") != $(realpath -m "$SECRETS_DIR/huggingface-token") ]]; then
|
||||||
|
install -m 0600 "$HF_TOKEN_FILE" "$SECRETS_DIR/huggingface-token"
|
||||||
|
HF_TOKEN_FILE=$SECRETS_DIR/huggingface-token
|
||||||
|
fi
|
||||||
[[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key
|
[[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key
|
||||||
[[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token
|
[[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token
|
||||||
chmod 0600 "$SECRETS_DIR"/*
|
chmod 0600 "$SECRETS_DIR"/*
|
||||||
@@ -309,11 +330,10 @@ MODEL_DIR=$MODEL_DIR
|
|||||||
WIREGUARD_CONFIG_FILE=${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}
|
WIREGUARD_CONFIG_FILE=${WIREGUARD_CONFIG_FILE:-/etc/mike-ai/wireguard/fritz-athena.conf}
|
||||||
ROUTER_API_KEY=$(<$SECRETS_DIR/router-api-key)
|
ROUTER_API_KEY=$(<$SECRETS_DIR/router-api-key)
|
||||||
CONTROLLER_TOKEN=$(<$SECRETS_DIR/controller-token)
|
CONTROLLER_TOKEN=$(<$SECRETS_DIR/controller-token)
|
||||||
PIPER_TTS_VERSION=${PIPER_TTS_VERSION:-1.6.0}
|
QWEN3_TTS_IMAGE=${QWEN3_TTS_IMAGE:-ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98}
|
||||||
PIPER_VOICE=${PIPER_VOICE:-de_DE-thorsten-high}
|
QWEN3_TTS_CACHE_DIR=$QWEN3_TTS_CACHE_DIR
|
||||||
XTTS_IMAGE=${XTTS_IMAGE:-ghcr.io/coqui-ai/xtts-streaming-server:latest-cuda121@sha256:f7fb3b1f9d4bc88af94da1b5959d8002f1e0b003c97557164034eb8a29f01b90}
|
QWEN3_TTS_VOICES_DIR=$QWEN3_TTS_VOICES_DIR
|
||||||
XTTS_CACHE_DIR=$XTTS_CACHE_DIR
|
QWEN3_TTS_GPU_DEVICE=${QWEN3_TTS_GPU_DEVICE:-GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b}
|
||||||
XTTS_GPU_DEVICE=${XTTS_GPU_DEVICE:-GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b}
|
|
||||||
AI_DNS=${WG_DNS:-1.1.1.1}
|
AI_DNS=${WG_DNS:-1.1.1.1}
|
||||||
FAST_MODEL_FILE=$FAST_MODEL_FILE
|
FAST_MODEL_FILE=$FAST_MODEL_FILE
|
||||||
MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE
|
MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE
|
||||||
@@ -321,14 +341,27 @@ LARGE_MODEL_FILE=$LARGE_MODEL_FILE
|
|||||||
ULTRA_MODEL_FILE=$ULTRA_MODEL_FILE
|
ULTRA_MODEL_FILE=$ULTRA_MODEL_FILE
|
||||||
UNCENSORED_MODEL_FILE=$UNCENSORED_MODEL_FILE
|
UNCENSORED_MODEL_FILE=$UNCENSORED_MODEL_FILE
|
||||||
UNCENSORED_PROJECTOR_FILE=$UNCENSORED_PROJECTOR_FILE
|
UNCENSORED_PROJECTOR_FILE=$UNCENSORED_PROJECTOR_FILE
|
||||||
EXPERIMENTAL_MODEL_FILE=$EXPERIMENTAL_MODEL_FILE
|
|
||||||
VISION_PROJECTOR_FILE=$VISION_PROJECTOR_FILE
|
VISION_PROJECTOR_FILE=$VISION_PROJECTOR_FILE
|
||||||
FAST_CONTEXT=${FAST_CONTEXT:-76800}
|
FAST_CONTEXT=${FAST_CONTEXT:-76800}
|
||||||
|
FAST_BATCH_SIZE=${FAST_BATCH_SIZE:-64}
|
||||||
|
FAST_UBATCH_SIZE=${FAST_UBATCH_SIZE:-32}
|
||||||
|
FAST_PARALLEL_SLOTS=${FAST_PARALLEL_SLOTS:-1}
|
||||||
MEDIUM_CONTEXT=${MEDIUM_CONTEXT:-160000}
|
MEDIUM_CONTEXT=${MEDIUM_CONTEXT:-160000}
|
||||||
|
MEDIUM_BATCH_SIZE=${MEDIUM_BATCH_SIZE:-2048}
|
||||||
|
MEDIUM_UBATCH_SIZE=${MEDIUM_UBATCH_SIZE:-128}
|
||||||
|
MEDIUM_PARALLEL_SLOTS=${MEDIUM_PARALLEL_SLOTS:-1}
|
||||||
LARGE_CONTEXT=${LARGE_CONTEXT:-192000}
|
LARGE_CONTEXT=${LARGE_CONTEXT:-192000}
|
||||||
|
LARGE_BATCH_SIZE=${LARGE_BATCH_SIZE:-2048}
|
||||||
|
LARGE_UBATCH_SIZE=${LARGE_UBATCH_SIZE:-128}
|
||||||
|
LARGE_PARALLEL_SLOTS=${LARGE_PARALLEL_SLOTS:-1}
|
||||||
ULTRA_CONTEXT=${ULTRA_CONTEXT:-262144}
|
ULTRA_CONTEXT=${ULTRA_CONTEXT:-262144}
|
||||||
|
ULTRA_BATCH_SIZE=${ULTRA_BATCH_SIZE:-2048}
|
||||||
|
ULTRA_UBATCH_SIZE=${ULTRA_UBATCH_SIZE:-128}
|
||||||
|
ULTRA_PARALLEL_SLOTS=${ULTRA_PARALLEL_SLOTS:-1}
|
||||||
UNCENSORED_CONTEXT=${UNCENSORED_CONTEXT:-80000}
|
UNCENSORED_CONTEXT=${UNCENSORED_CONTEXT:-80000}
|
||||||
EXPERIMENTAL_CONTEXT=${EXPERIMENTAL_CONTEXT:-76800}
|
UNCENSORED_BATCH_SIZE=${UNCENSORED_BATCH_SIZE:-2048}
|
||||||
|
UNCENSORED_UBATCH_SIZE=${UNCENSORED_UBATCH_SIZE:-128}
|
||||||
|
UNCENSORED_PARALLEL_SLOTS=${UNCENSORED_PARALLEL_SLOTS:-1}
|
||||||
FAST_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
FAST_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
||||||
MEDIUM_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
MEDIUM_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
||||||
MEDIUM_TENSOR_SPLIT=${MEDIUM_TENSOR_SPLIT:-90,10}
|
MEDIUM_TENSOR_SPLIT=${MEDIUM_TENSOR_SPLIT:-90,10}
|
||||||
@@ -339,15 +372,40 @@ ULTRA_TENSOR_SPLIT=${ULTRA_TENSOR_SPLIT:-80,20}
|
|||||||
UNCENSORED_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
UNCENSORED_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}${SECONDARY_GPU_DEVICES:+,$SECONDARY_GPU_DEVICES}
|
||||||
UNCENSORED_TENSOR_SPLIT=${UNCENSORED_TENSOR_SPLIT:-90,10}
|
UNCENSORED_TENSOR_SPLIT=${UNCENSORED_TENSOR_SPLIT:-90,10}
|
||||||
UNCENSORED_MTP_MAX=${UNCENSORED_MTP_MAX:-2}
|
UNCENSORED_MTP_MAX=${UNCENSORED_MTP_MAX:-2}
|
||||||
EXPERIMENTAL_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
|
|
||||||
IMAGE_GPU_DEVICES=${IMAGE_GPU_DEVICES:-${TEXT_GPU_DEVICES:-0}}
|
IMAGE_GPU_DEVICES=${IMAGE_GPU_DEVICES:-${TEXT_GPU_DEVICES:-0}}
|
||||||
FLUX_MODEL_DIR=${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}
|
FLUX_COMPONENT_DIR=${FLUX_COMPONENT_DIR:-/data/models/FLUX.2-klein-9B-components}
|
||||||
|
FLUX_TRANSFORMER_DIR=${FLUX_TRANSFORMER_DIR:-/data/models/FLUX.2-klein-9B-fp8}
|
||||||
LLAMA_THREADS=${LLAMA_THREADS:-6}
|
LLAMA_THREADS=${LLAMA_THREADS:-6}
|
||||||
LLAMA_THREADS_BATCH=${LLAMA_THREADS_BATCH:-6}
|
LLAMA_THREADS_BATCH=${LLAMA_THREADS_BATCH:-6}
|
||||||
|
LLAMA_CACHE_RAM_MIB=${LLAMA_CACHE_RAM_MIB:-32768}
|
||||||
|
DEFAULT_REASONING_EFFORT=${DEFAULT_REASONING_EFFORT:-off}
|
||||||
EOF
|
EOF
|
||||||
chmod 0600 $SECRETS_DIR/stack.env
|
chmod 0600 $SECRETS_DIR/stack.env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_disaster_backup() {
|
||||||
|
log "Externes Disaster-Backup installieren"
|
||||||
|
install -m 0755 "$ROOT_DIR/platform/backup/athena-disaster-backup" \
|
||||||
|
/usr/local/sbin/athena-disaster-backup
|
||||||
|
install -m 0644 "$ROOT_DIR/platform/backup/athena-disaster-backup.service" \
|
||||||
|
/etc/systemd/system/athena-disaster-backup.service
|
||||||
|
install -m 0644 "$ROOT_DIR/platform/backup/athena-disaster-backup.timer" \
|
||||||
|
/etc/systemd/system/athena-disaster-backup.timer
|
||||||
|
install -m 0755 "$ROOT_DIR/platform/backup/athena-export-backup" \
|
||||||
|
/usr/local/sbin/athena-export-backup
|
||||||
|
install -m 0644 "$ROOT_DIR/platform/backup/athena-export-backup.service" \
|
||||||
|
/etc/systemd/system/athena-export-backup.service
|
||||||
|
install -m 0644 "$ROOT_DIR/platform/backup/athena-export-backup.timer" \
|
||||||
|
/etc/systemd/system/athena-export-backup.timer
|
||||||
|
if [[ ! -e $SECRETS_DIR/disaster-backup.env ]]; then
|
||||||
|
install -m 0600 "$ROOT_DIR/config/disaster-backup.env.example" \
|
||||||
|
"$SECRETS_DIR/disaster-backup.env.example"
|
||||||
|
fi
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now athena-disaster-backup.timer
|
||||||
|
systemctl enable --now athena-export-backup.timer
|
||||||
|
}
|
||||||
|
|
||||||
download_one() {
|
download_one() {
|
||||||
local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1"
|
local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1"
|
||||||
install -d -m 0755 "$(dirname "$target")"
|
install -d -m 0755 "$(dirname "$target")"
|
||||||
@@ -382,7 +440,6 @@ $LARGE_MODEL_FILE|$LARGE_MODEL_URL|$LARGE_MODEL_SHA256
|
|||||||
$ULTRA_MODEL_FILE|$ULTRA_MODEL_URL|$ULTRA_MODEL_SHA256
|
$ULTRA_MODEL_FILE|$ULTRA_MODEL_URL|$ULTRA_MODEL_SHA256
|
||||||
$UNCENSORED_MODEL_FILE|$UNCENSORED_MODEL_URL|$UNCENSORED_MODEL_SHA256
|
$UNCENSORED_MODEL_FILE|$UNCENSORED_MODEL_URL|$UNCENSORED_MODEL_SHA256
|
||||||
$UNCENSORED_PROJECTOR_FILE|$UNCENSORED_PROJECTOR_URL|$UNCENSORED_PROJECTOR_SHA256
|
$UNCENSORED_PROJECTOR_FILE|$UNCENSORED_PROJECTOR_URL|$UNCENSORED_PROJECTOR_SHA256
|
||||||
$EXPERIMENTAL_MODEL_FILE|$EXPERIMENTAL_MODEL_URL|$EXPERIMENTAL_MODEL_SHA256
|
|
||||||
$VISION_PROJECTOR_FILE|$VISION_PROJECTOR_URL|$VISION_PROJECTOR_SHA256
|
$VISION_PROJECTOR_FILE|$VISION_PROJECTOR_URL|$VISION_PROJECTOR_SHA256
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -460,23 +517,38 @@ build_and_start() {
|
|||||||
docker build --progress=plain --build-arg LLAMA_CPP_COMMIT="$commit" \
|
docker build --progress=plain --build-arg LLAMA_CPP_COMMIT="$commit" \
|
||||||
-f platform/docker/llama-cpp/Dockerfile -t mike-ai/llama.cpp:local .
|
-f platform/docker/llama-cpp/Dockerfile -t mike-ai/llama.cpp:local .
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" --profile image build image-worker
|
docker compose --env-file "$SECRETS_DIR/stack.env" --profile image build image-worker
|
||||||
if [[ ! -s ${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}/model_index.json ]]; then
|
local flux_components=${FLUX_COMPONENT_DIR:-/data/models/FLUX.2-klein-9B-components}
|
||||||
log "FLUX.2-klein-4B laden"
|
local flux_transformer=${FLUX_TRANSFORMER_DIR:-/data/models/FLUX.2-klein-9B-fp8}
|
||||||
install -d -m 0755 "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}"
|
local hf_token_file=${HF_TOKEN_FILE:-/root/.cache/huggingface/token}
|
||||||
docker run --rm --entrypoint python \
|
if [[ ! -s $flux_components/model_index.json || \
|
||||||
-v "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}:/download" \
|
! -s $flux_transformer/flux-2-klein-9b-fp8.safetensors ]]; then
|
||||||
|
[[ -r $hf_token_file ]] || die \
|
||||||
|
"Hugging-Face-Token fehlt: $hf_token_file (FLUX.2 Klein 9B ist gated)"
|
||||||
|
log "FLUX.2 Klein 9B Komponenten und FP8-Transformer laden"
|
||||||
|
install -d -m 0755 "$flux_components" "$flux_transformer"
|
||||||
|
docker run --rm --entrypoint /opt/image-venv/bin/python \
|
||||||
|
-e HF_TOKEN_PATH=/run/secrets/hf-token \
|
||||||
|
-v "$hf_token_file:/run/secrets/hf-token:ro" \
|
||||||
|
-v "$flux_components:/download" \
|
||||||
mike-ai/image-worker:local -c \
|
mike-ai/image-worker:local -c \
|
||||||
"from huggingface_hub import snapshot_download; snapshot_download('black-forest-labs/FLUX.2-klein-4B', revision='e7b7dc27f91deacad38e78976d1f2b499d76a294', local_dir='/download')"
|
"from huggingface_hub import snapshot_download; snapshot_download('black-forest-labs/FLUX.2-klein-9B', revision='92196c8e11f7b6cf2b7493e037d8c5345c559216', local_dir='/download', allow_patterns=['model_index.json', 'scheduler/*', 'text_encoder/*', 'tokenizer/*', 'transformer/config.json', 'vae/*'])"
|
||||||
chmod -R a-w "${FLUX_MODEL_DIR:-/data/models/FLUX.2-klein-4B}"
|
docker run --rm --entrypoint /opt/image-venv/bin/python \
|
||||||
|
-e HF_TOKEN_PATH=/run/secrets/hf-token \
|
||||||
|
-v "$hf_token_file:/run/secrets/hf-token:ro" \
|
||||||
|
-v "$flux_transformer:/download" \
|
||||||
|
mike-ai/image-worker:local -c \
|
||||||
|
"from huggingface_hub import snapshot_download; snapshot_download('black-forest-labs/FLUX.2-klein-9b-fp8', revision='902d9d510b51533e07729f19211414a3648b77d2', local_dir='/download', allow_patterns=['flux-2-klein-9b-fp8.safetensors', 'README.md', 'LICENSE.md'])"
|
||||||
|
chmod -R a-w "$flux_components" "$flux_transformer"
|
||||||
fi
|
fi
|
||||||
# Creates the tools network and deploys the only host-bound MCP: Operator.
|
# Creates the tools network and deploys the only host-bound MCP: Operator.
|
||||||
# Portable MCPs and Hermes live on Unraid and are restored through Appdata.
|
# Portable MCPs and Hermes live on Unraid and are restored through Appdata.
|
||||||
"$STACK_DIR/platform/mcp/install-tools.sh"
|
"$STACK_DIR/platform/mcp/install-tools.sh"
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" --profile inference create \
|
docker compose --env-file "$SECRETS_DIR/stack.env" --profile inference create \
|
||||||
llama-fast llama-medium llama-large llama-ultra llama-experimental
|
llama-fast llama-medium llama-large llama-ultra llama-uncensored
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" --profile image create image-worker
|
docker compose --env-file "$SECRETS_DIR/stack.env" --profile image create image-worker
|
||||||
|
docker volume inspect portainer_data >/dev/null 2>&1 || docker volume create portainer_data >/dev/null
|
||||||
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \
|
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \
|
||||||
wireguard-gateway xtts piper tts-gateway profile-controller router llama-dashboard backup
|
wireguard-gateway qwen3-tts tts-gateway profile-controller router llama-dashboard portainer backup
|
||||||
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
|
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
|
||||||
systemctl restart mike-ai-container-vpn-guard.service
|
systemctl restart mike-ai-container-vpn-guard.service
|
||||||
fi
|
fi
|
||||||
@@ -537,6 +609,7 @@ install_stack_files
|
|||||||
download_models
|
download_models
|
||||||
install_routing_guard
|
install_routing_guard
|
||||||
build_and_start
|
build_and_start
|
||||||
|
install_disaster_backup
|
||||||
|
|
||||||
log "Installation abgeschlossen"
|
log "Installation abgeschlossen"
|
||||||
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
|
if [[ ${WIREGUARD_MODE:-container} == container ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Hermes Athena image provider
|
||||||
|
|
||||||
|
Hermes backend plugin for the OpenAI-compatible image API exposed by the
|
||||||
|
Athena profile router. The router starts the local FLUX worker on demand,
|
||||||
|
unloads the active LLM and Qwen3-TTS, and restores both after generation.
|
||||||
|
|
||||||
|
Reference-image requests use FLUX for creative edits. No second Hermes
|
||||||
|
provider or desktop installation is required.
|
||||||
|
|
||||||
|
There is deliberately no photo-restoration model or restoration skill in this
|
||||||
|
provider. The former HYPIR experiment was removed after it redrew and smoothed
|
||||||
|
details instead of preserving the source faithfully. See
|
||||||
|
[`docs/IMAGE_RESTORATION.md`](../../docs/IMAGE_RESTORATION.md) for the recorded
|
||||||
|
decision and the isolated SeedVR2 comparison.
|
||||||
|
|
||||||
|
## Gateway installation
|
||||||
|
|
||||||
|
Install this directory on the Hermes gateway, not on each Desktop client:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$HERMES_HOME/plugins/image_gen/athena-local/
|
||||||
|
__init__.py
|
||||||
|
plugin.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Set these secrets or environment variables on the gateway:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ATHENA_IMAGE_BASE_URL=http://192.168.1.212:8081/v1
|
||||||
|
ATHENA_IMAGE_API_KEY=<same API key accepted by the Athena router>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then enable and select the provider:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
plugins:
|
||||||
|
enabled:
|
||||||
|
- image_gen/athena-local
|
||||||
|
|
||||||
|
image_gen:
|
||||||
|
provider: athena-local
|
||||||
|
model: FLUX.2-klein-9B-fp8-beta
|
||||||
|
max_parallel_requests: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Hermes gateway after changing plugin files or configuration. A
|
||||||
|
second computer connected to the same gateway needs no plugin installation.
|
||||||
|
|
||||||
|
Optional overrides:
|
||||||
|
|
||||||
|
- `ATHENA_IMAGE_MODEL` defaults to `FLUX.2-klein-9B-fp8-beta`.
|
||||||
|
- `ROUTER_API_KEY` is accepted as a migration fallback.
|
||||||
|
- An existing `HERMES_CUSTOM_192_168_1_212_8081_API_KEY` is accepted as the
|
||||||
|
final fallback, so an existing Athena chat-provider setup needs no duplicate
|
||||||
|
secret.
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Hermes image generation/edit provider for the local Athena router.
|
||||||
|
|
||||||
|
The provider deliberately rejects public destinations. Prompts and generated
|
||||||
|
images may only travel to a loopback or private-network address.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from agent.image_gen_provider import (
|
||||||
|
DEFAULT_ASPECT_RATIO,
|
||||||
|
ImageGenProvider,
|
||||||
|
error_response,
|
||||||
|
normalize_reference_images,
|
||||||
|
resolve_aspect_ratio,
|
||||||
|
save_b64_image,
|
||||||
|
success_response,
|
||||||
|
)
|
||||||
|
from agent.secret_scope import get_secret
|
||||||
|
|
||||||
|
|
||||||
|
_SIZES = {
|
||||||
|
"landscape": "1536x1024",
|
||||||
|
"square": "1024x1024",
|
||||||
|
"portrait": "1024x1536",
|
||||||
|
}
|
||||||
|
_DEFAULT_BASE_URL = "http://192.168.1.212:8081/v1"
|
||||||
|
_DEFAULT_MODEL = "FLUX.2-klein-9B-fp8-beta"
|
||||||
|
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url() -> str:
|
||||||
|
"""Use the dedicated image URL and never inherit an unrelated chat URL."""
|
||||||
|
override = os.environ.get("ATHENA_IMAGE_BASE_URL", "").strip()
|
||||||
|
return (override or _DEFAULT_BASE_URL).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _model() -> str:
|
||||||
|
return os.environ.get("ATHENA_IMAGE_MODEL", "").strip() or _DEFAULT_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
def _api_key() -> str:
|
||||||
|
"""Prefer a scoped key; accept the existing router key for migration."""
|
||||||
|
return (
|
||||||
|
get_secret("ATHENA_IMAGE_API_KEY", "")
|
||||||
|
or get_secret("ROUTER_API_KEY", "")
|
||||||
|
or get_secret("HERMES_CUSTOM_192_168_1_212_8081_API_KEY", "")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _private_destination(url: str) -> bool:
|
||||||
|
"""Fail closed unless the configured endpoint is local/private."""
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
return False
|
||||||
|
if parsed.hostname == "localhost":
|
||||||
|
return True
|
||||||
|
address = ipaddress.ip_address(parsed.hostname)
|
||||||
|
return address.is_private or address.is_loopback
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_private_image(ref: str) -> bytes:
|
||||||
|
"""Load a local/data/private-LAN image without contacting public hosts."""
|
||||||
|
ref = ref.strip()
|
||||||
|
lower = ref.lower()
|
||||||
|
if lower.startswith("data:image/"):
|
||||||
|
_, separator, payload = ref.partition(",")
|
||||||
|
if not separator:
|
||||||
|
raise ValueError("invalid image data URI")
|
||||||
|
data = base64.b64decode(payload, validate=True)
|
||||||
|
elif lower.startswith(("http://", "https://")):
|
||||||
|
if not _private_destination(ref):
|
||||||
|
raise ValueError("public reference-image URLs are blocked")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
ref, headers={"User-Agent": "Hermes-Athena-Image/2.0"})
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
data = response.read(_MAX_IMAGE_BYTES + 1)
|
||||||
|
else:
|
||||||
|
from agent.file_safety import raise_if_read_blocked
|
||||||
|
raise_if_read_blocked(ref)
|
||||||
|
with open(ref, "rb") as image_file:
|
||||||
|
data = image_file.read(_MAX_IMAGE_BYTES + 1)
|
||||||
|
if not data or len(data) > _MAX_IMAGE_BYTES:
|
||||||
|
raise ValueError("reference image is empty or exceeds 20 MiB")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class AthenaLocalImageProvider(ImageGenProvider):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "athena-local"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
return "Athena Local (FLUX.2 Klein)"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return bool(_api_key()) and _private_destination(_base_url())
|
||||||
|
|
||||||
|
def list_models(self) -> List[Dict[str, Any]]:
|
||||||
|
return [{
|
||||||
|
"id": _model(),
|
||||||
|
"display": "FLUX.2 Klein 9B FP8 Beta on Athena",
|
||||||
|
"speed": "local",
|
||||||
|
"strengths": "Private local generation and multi-reference editing",
|
||||||
|
"price": "local / no cloud",
|
||||||
|
}]
|
||||||
|
|
||||||
|
def default_model(self) -> Optional[str]:
|
||||||
|
return _model()
|
||||||
|
|
||||||
|
def capabilities(self) -> Dict[str, Any]:
|
||||||
|
return {"modalities": ["text", "image"], "max_reference_images": 3}
|
||||||
|
|
||||||
|
def get_setup_schema(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": "Athena Local (FLUX.2 Klein)",
|
||||||
|
"badge": "local",
|
||||||
|
"tag": "Private image generation on Athena; public endpoints are rejected",
|
||||||
|
"env_vars": [
|
||||||
|
{"key": "ATHENA_IMAGE_API_KEY", "prompt": "Athena router API key"},
|
||||||
|
{
|
||||||
|
"key": "ATHENA_IMAGE_BASE_URL",
|
||||||
|
"prompt": "Athena image API base URL",
|
||||||
|
"default": _DEFAULT_BASE_URL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||||
|
*,
|
||||||
|
image_url: Optional[str] = None,
|
||||||
|
reference_image_urls: Optional[List[str]] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
clean_prompt = (prompt or "").strip()
|
||||||
|
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||||
|
base_url = _base_url()
|
||||||
|
|
||||||
|
if not clean_prompt:
|
||||||
|
return error_response(
|
||||||
|
error="Prompt is required.", error_type="invalid_argument",
|
||||||
|
provider=self.name, aspect_ratio=aspect)
|
||||||
|
if not _private_destination(base_url):
|
||||||
|
return error_response(
|
||||||
|
error=("Athena image endpoint is not a private-network "
|
||||||
|
"destination; request blocked."),
|
||||||
|
error_type="unsafe_destination", provider=self.name,
|
||||||
|
prompt=clean_prompt, aspect_ratio=aspect)
|
||||||
|
|
||||||
|
model = _model()
|
||||||
|
api_key = _api_key()
|
||||||
|
if not api_key:
|
||||||
|
return error_response(
|
||||||
|
error=("No Athena router key is configured. Set "
|
||||||
|
"ATHENA_IMAGE_API_KEY or reuse "
|
||||||
|
"HERMES_CUSTOM_192_168_1_212_8081_API_KEY."),
|
||||||
|
error_type="auth_required", provider=self.name, model=model,
|
||||||
|
prompt=clean_prompt, aspect_ratio=aspect)
|
||||||
|
|
||||||
|
sources: List[str] = []
|
||||||
|
if isinstance(image_url, str) and image_url.strip():
|
||||||
|
sources.append(image_url.strip())
|
||||||
|
sources.extend(normalize_reference_images(reference_image_urls) or [])
|
||||||
|
# Hermes may expose the primary upload through both ``image_url`` and
|
||||||
|
# ``reference_image_urls``. Preserve order while removing duplicates.
|
||||||
|
sources = list(dict.fromkeys(sources))[:4]
|
||||||
|
model = _model()
|
||||||
|
try:
|
||||||
|
encoded_sources = [
|
||||||
|
base64.b64encode(_load_private_image(source)).decode("ascii")
|
||||||
|
for source in sources
|
||||||
|
]
|
||||||
|
except Exception as exc:
|
||||||
|
return error_response(
|
||||||
|
error=f"Reference image could not be loaded locally: {exc}",
|
||||||
|
error_type="io_error", provider=self.name, model=model,
|
||||||
|
prompt=clean_prompt, aspect_ratio=aspect)
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": clean_prompt,
|
||||||
|
"size": _SIZES[aspect],
|
||||||
|
"n": 1,
|
||||||
|
"quality": "standard",
|
||||||
|
"steps": 4,
|
||||||
|
"guidance": 1.0,
|
||||||
|
"response_format": "b64_json",
|
||||||
|
}
|
||||||
|
endpoint = "generations"
|
||||||
|
if encoded_sources:
|
||||||
|
endpoint = "edits"
|
||||||
|
request_data["image_b64"] = encoded_sources[0]
|
||||||
|
request_data["reference_images_b64"] = encoded_sources[1:]
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{base_url}/images/{endpoint}",
|
||||||
|
data=json.dumps(request_data).encode("utf-8"), method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=900) as response:
|
||||||
|
result = json.load(response)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
try:
|
||||||
|
detail = exc.read(4096).decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
detail = ""
|
||||||
|
return error_response(
|
||||||
|
error=f"Athena image request failed (HTTP {exc.code}): {detail[:500]}",
|
||||||
|
error_type="api_error", provider=self.name, model=model,
|
||||||
|
prompt=clean_prompt, aspect_ratio=aspect)
|
||||||
|
except (OSError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
return error_response(
|
||||||
|
error=f"Athena image request failed: {exc}",
|
||||||
|
error_type="connection_error", provider=self.name, model=model,
|
||||||
|
prompt=clean_prompt, aspect_ratio=aspect)
|
||||||
|
|
||||||
|
items = result.get("data") if isinstance(result, dict) else None
|
||||||
|
first = items[0] if isinstance(items, list) and items else None
|
||||||
|
b64_data = first.get("b64_json") if isinstance(first, dict) else None
|
||||||
|
if not isinstance(b64_data, str) or not b64_data:
|
||||||
|
return error_response(
|
||||||
|
error="Athena returned no image data.",
|
||||||
|
error_type="empty_response", provider=self.name, model=model,
|
||||||
|
prompt=clean_prompt, aspect_ratio=aspect)
|
||||||
|
|
||||||
|
try:
|
||||||
|
saved = save_b64_image(b64_data, prefix="athena_flux2")
|
||||||
|
except Exception as exc:
|
||||||
|
return error_response(
|
||||||
|
error=f"Generated image could not be saved: {exc}",
|
||||||
|
error_type="io_error", provider=self.name, model=model,
|
||||||
|
prompt=clean_prompt, aspect_ratio=aspect)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
image=str(saved), model=model, prompt=clean_prompt,
|
||||||
|
aspect_ratio=aspect, provider=self.name,
|
||||||
|
modality="image" if encoded_sources else "text",
|
||||||
|
extra={"size": _SIZES[aspect], "local_only": True,
|
||||||
|
"reference_images": len(encoded_sources)})
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx) -> None:
|
||||||
|
ctx.register_image_gen_provider(AthenaLocalImageProvider())
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
name: athena-local
|
||||||
|
version: 2.2.0
|
||||||
|
description: "Local-only FLUX.2 Klein 9B FP8 beta generation and editing through Athena."
|
||||||
|
author: Michael
|
||||||
|
kind: backend
|
||||||
|
requires_env:
|
||||||
|
- HERMES_CUSTOM_192_168_1_212_8081_API_KEY
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Hermes Qwen3-TTS PCM streaming adapter
|
||||||
|
|
||||||
|
This optional Hermes backend plugin uses Athena's native
|
||||||
|
`/v1/audio/speech/pcm-stream` route. It starts playback while Qwen3-TTS is
|
||||||
|
still synthesizing the current sentence instead of waiting for a complete
|
||||||
|
audio file.
|
||||||
|
|
||||||
|
Install this directory as `${HERMES_HOME}/plugins/qwen3-stream`, enable the
|
||||||
|
plugin and set:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tts:
|
||||||
|
provider: qwen3-stream
|
||||||
|
streaming:
|
||||||
|
provider: qwen3-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter reuses `tts.openai.base_url`, `tts.openai.api_key`, model, voice
|
||||||
|
and language unless an explicit `tts.qwen3-stream` section overrides them.
|
||||||
|
This avoids copying the Athena credential into another file.
|
||||||
|
|
||||||
|
Rollback is immediate: restore `tts.provider` and `tts.streaming.provider` to
|
||||||
|
`openai`, disable the plugin and restart the Hermes gateway.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterator, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from agent.tts_provider import TTSProvider
|
||||||
|
from tools.tool_backend_helpers import resolve_openai_audio_api_key
|
||||||
|
from tools.tts_streaming import StreamingTTSProvider, register as register_streamer
|
||||||
|
from tools.tts_tool import _load_tts_config
|
||||||
|
|
||||||
|
|
||||||
|
NAME = "qwen3-stream"
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
|
||||||
|
|
||||||
|
def _settings() -> Dict[str, Any]:
|
||||||
|
config = _load_tts_config()
|
||||||
|
own = dict(config.get(NAME) or {})
|
||||||
|
fallback = dict(config.get("openai") or {})
|
||||||
|
own.setdefault("base_url", fallback.get("base_url", ""))
|
||||||
|
own.setdefault("api_key", fallback.get("api_key", ""))
|
||||||
|
own.setdefault("model", fallback.get("model", "tts-1"))
|
||||||
|
own.setdefault("voice", fallback.get("voice", "alloy"))
|
||||||
|
own.setdefault("language", fallback.get("language", "German"))
|
||||||
|
own.setdefault("chunk_size", 4)
|
||||||
|
return own
|
||||||
|
|
||||||
|
|
||||||
|
def _url(path: str, section: Optional[Dict[str, Any]] = None) -> str:
|
||||||
|
cfg = section or _settings()
|
||||||
|
base = str(cfg.get("base_url") or "").rstrip("/")
|
||||||
|
if not base:
|
||||||
|
raise RuntimeError("tts.qwen3-stream.base_url is not configured")
|
||||||
|
if not base.endswith("/v1"):
|
||||||
|
base += "/v1"
|
||||||
|
return base + path
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(section: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
|
||||||
|
cfg = section or _settings()
|
||||||
|
key = str(cfg.get("api_key") or resolve_openai_audio_api_key() or "").strip()
|
||||||
|
headers = {"Accept": "application/octet-stream"}
|
||||||
|
if key:
|
||||||
|
headers["Authorization"] = f"Bearer {key}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(text: str, section: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
cfg = section or _settings()
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"input": text,
|
||||||
|
"model": cfg.get("model") or "tts-1",
|
||||||
|
"voice": cfg.get("voice") or "alloy",
|
||||||
|
"language": cfg.get("language") or "German",
|
||||||
|
}
|
||||||
|
instruct = str(cfg.get("instruct") or "").strip()
|
||||||
|
if instruct:
|
||||||
|
payload["instruct"] = instruct
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class Qwen3PCMStreamer(StreamingTTSProvider):
|
||||||
|
sample_rate = SAMPLE_RATE
|
||||||
|
channels = 1
|
||||||
|
sample_width = 2
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def available() -> bool:
|
||||||
|
try:
|
||||||
|
return bool(_settings().get("base_url"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stream(self, text: str) -> Iterator[bytes]:
|
||||||
|
cfg = dict(_settings())
|
||||||
|
cfg.update(self.section or {})
|
||||||
|
payload = _payload(text, cfg)
|
||||||
|
payload["chunk_size"] = max(1, int(cfg.get("chunk_size", 4)))
|
||||||
|
with requests.post(
|
||||||
|
_url("/audio/speech/pcm-stream", cfg),
|
||||||
|
json=payload,
|
||||||
|
headers=_headers(cfg),
|
||||||
|
stream=True,
|
||||||
|
timeout=(5, 120),
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
pending = b""
|
||||||
|
# An explicit read size prevents urllib3 from buffering the
|
||||||
|
# unknown-length response until connection close.
|
||||||
|
for chunk in response.iter_content(chunk_size=4096):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
data = pending + chunk
|
||||||
|
even = len(data) & ~1
|
||||||
|
if even:
|
||||||
|
yield data[:even]
|
||||||
|
pending = data[even:]
|
||||||
|
|
||||||
|
|
||||||
|
class Qwen3TTSProvider(TTSProvider):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return NAME
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
return "Athena Qwen3-TTS Streaming"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return Qwen3PCMStreamer.available()
|
||||||
|
|
||||||
|
def list_voices(self) -> List[Dict[str, Any]]:
|
||||||
|
voice = str(_settings().get("voice") or "alloy")
|
||||||
|
return [{"id": voice, "display": voice, "language": "de"}]
|
||||||
|
|
||||||
|
def synthesize(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
output_path: str,
|
||||||
|
*,
|
||||||
|
voice: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
speed: Optional[float] = None,
|
||||||
|
format: str = "mp3",
|
||||||
|
**extra: Any,
|
||||||
|
) -> str:
|
||||||
|
cfg = _settings()
|
||||||
|
payload = _payload(text, cfg)
|
||||||
|
payload["response_format"] = format
|
||||||
|
if voice:
|
||||||
|
payload["voice"] = voice
|
||||||
|
if model:
|
||||||
|
payload["model"] = model
|
||||||
|
if speed is not None:
|
||||||
|
payload["speed"] = speed
|
||||||
|
response = requests.post(
|
||||||
|
_url("/audio/speech", cfg),
|
||||||
|
json=payload,
|
||||||
|
headers=_headers(cfg),
|
||||||
|
timeout=(5, 120),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
Path(output_path).write_bytes(response.content)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
register_streamer(NAME)(Qwen3PCMStreamer)
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx) -> None:
|
||||||
|
ctx.register_tts_provider(Qwen3TTSProvider())
|
||||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user