STT und TTS auf die zweite GPU: der MSI-Laptop übernimmt
Am Ende von Teil 1 habe ich zwei Dinge
versprochen: den TTS-Rückweg, damit Assist antwortet statt nur zu schreiben, und
einen ehrlichen Versuch mit large-v3-turbo auf GPU — sobald ich dem Chatbot,
der auf der RTX 2080 Ti wohnt, ein kleineres num_ctx verpasst habe.
Diese Diät hat nie stattgefunden.
Wie weit kann man den Kontext eines Modells zusammenstreichen, das echte Leute auf einer Live-Seite benutzen, bevor es dumm wird? Ich weiß es bis heute nicht, weil ich mich nicht getraut habe, es im Livebetrieb herauszufinden. Die Lösung stand stattdessen schon im Haus: mein MSI-Laptop mit einer zweiten, neueren GPU.
Das ist die unspektakuläre Pointe dieses Artikels. Ich habe kein Problem gelöst, ich habe es umgezogen.
Die Rechnung, an der alles gescheitert ist
Die 2080 Ti hat 11,2 GB nutzbares VRAM. Darauf wollten zwei Mieter gleichzeitig:
gemma4:e4b— der Gäste-Chatbot der Hochzeitsseite. Eine Live-Seite, echte Gäste tippen da rein, die Anfragen kommen über einen SSH-Reverse-Tunnel bei meinem Ollama an. Braucht 9,6 GB. Es ist das einzige lokale Modell, das österreichisches Deutsch und die Fakten hinbekommt, also war “nimm halt ein kleineres” keine Option.- Whisper
large-v3-turbound die TTS-Stimme zusammen: 5,2 GB.
9,6 + 5,2 > 11,2. Keine Diskussion, keine Optimierung, kein Trick. Das Ergebnis war, dass Ollama abwechselnd das eine für das andere rausgeworfen hat — und das merkst du nicht an einer Fehlermeldung, sondern daran, dass eine Sprachanfrage manchmal doppelt so lange dauert wie die davor.
Der einzige Weg, wie beide Modelle dauerhaft geladen bleiben, sind zwei Karten. Und ich hatte zwei Karten, ich hatte sie nur nie als zwei Karten gesehen.
Der zweite Rechner: ein Laptop, der früher gereist ist
MSI-Laptop, NVIDIA GeForce RTX 3070 Laptop GPU, 8 GB (8,59 GB laut Tooling).
Ampere, also zwei Generationen jünger als die 2080 Ti im Server. Läuft Windows;
die Dienste laufen in WSL2 (Ubuntu) mit systemd=true, RAM auf 7 GB
gedeckelt, 16 CPU-Threads.
Der Zustand, in dem ich diese WSL-Installation vorgefunden habe, ist der eigentliche
Grund, warum daraus ein Artikel wurde. Vorhanden: Python 3.12 und der
CUDA-Treiberpfad von Windows (/dev/dxg, /usr/lib/wsl/lib/libcuda.so).
Nicht vorhanden: torch, nvidia-smi, Docker, pip, python3-venv. Dazu ein
sudo, das ein Passwort will, und ein WSL-Benutzer, der unter Windows kein
Administrator ist.
Also alles im Userspace mit uv. Kein apt install, kein Container, keine
Root-Rechte. Das klingt nach Selbstkasteiung, hat aber einen angenehmen
Nebeneffekt: Was so läuft, läuft nachvollziehbar in genau einem Verzeichnis.
Kleine Randnotiz, über die ich beim Netzwerk-Scan erst gestolpert bin: Die MAC des Laptops meldet sich als “Intel Corporate”, nicht als MSI. Laptops zeigen den Hersteller des WLAN-Chipsatzes an. Wer sein eigenes Netz nach “dem MSI” absucht, sucht vergeblich.
Falle 1: faster-whisper zieht sich CUDA nicht selbst
Das ist die teuerste Falle des ganzen Umzugs, weil sie lautlos ist.
faster-whisper installiert die CUDA-Bibliotheken nicht mit. Fehlen sie, stirbt
der Dienst nicht — er fällt kommentarlos auf cpu/int8 zurück und arbeitet
brav weiter. Im Journal steht dazu genau eine Zeile:
could not load large-v3-turbo on cuda (Library libcublas.so.12 is not found)
Der Unterschied, den diese eine Zeile versteckt:
| Device | Zeit pro Transkription |
|---|---|
cuda/float16 |
0,47 s |
stiller Fallback auf cpu/int8 |
10,8 s |
Fix:
uv pip install nvidia-cublas-cu12 nvidia-cudnn-cu12==9.*
Und die Lektion, die ich mir seitdem an jede Neuinstallation klebe: Nach jedem
Reinstall das Journal nach on cuda gegen on cpu durchsuchen. Nicht nach
Fehlern — die gibt es nicht. Wenn es läuft, sagt es:
wyoming-whisper: loaded large-v3-turbo on cuda/float16 in 4.1s
Falle 2: torchaudio zieht sich das falsche CUDA
Die Requirements haben torchaudio auf einen CUDA-13-Build aufgelöst, während
torch auf 2.6.0+cu124 stand. Der TTS-Server ist dann mit dem hier gestorben:
OSError: libcudart.so.13: cannot open shared object file
Fix ist ein Pin, kein Debugging:
uv pip install "torchaudio==2.6.0" --torch-backend=cu124
Das ist der Moment, in dem man merkt, dass “lokal und ohne Cloud” nicht heißt “ohne fremde Abhängigkeiten”. Es heißt nur, dass die Abhängigkeiten bei mir auf der Platte liegen und nicht in einem Rechenzentrum, dessen AGB sich nächstes Jahr ändern.
Falle 3: die falsche Stimme, und warum sie schneller war
Die schönste Falle. espeak-ng ist ein apt-Paket, und ich habe kein Root. Ohne
espeak wirft phonemizer:
espeak not installed on your system
Die HAL-Synthese ist damit gescheitert — und der Server hat auf die Piper-Stimme zurückgeschaltet. Es kam also Audio. Das einzige Symptom von außen: falsche Stimme. Im Journal des TTS-Dienstes, 17. September zwischen 20:00 und 20:05, acht Stück davon:
ERROR hal-tts.server: HAL synthesis failed; trying piper fallback
Der Hinweis: Piper rendert in rund 0,15 s, HAL braucht rund 0,3 s. Die falsche Stimme zeigt sich also als verdächtig guter Benchmark. Ein plötzlich doppelt so schnelles System ist kein Erfolg, das ist ein Hinweis darauf, dass gerade etwas anderes läuft als gedacht.
Der Fix ohne Root: Das Wheel espeakng-loader bringt libespeak-ng.so selbst
mit. Die systemd-Unit setzt PHONEMIZER_ESPEAK_LIBRARY und ESPEAK_DATA_PATH
darauf, fertig. Kein apt, kein Admin, kein Anruf bei mir selbst.
Falle 4: das Log, das lügt
Der Shim auf dem Server (ha-hermes-ollama-shim.py) loggt
backend=qwen3-tts/voice-design
als fest verdrahteten String, völlig unabhängig davon, was das Audio tatsächlich gerendert hat. Wer dem Log glaubt, hat eine Stimme, die er nicht benutzt, und ein Log, das ihm das Gegenteil bestätigt.
Verlässlich ist nur das Journal auf der TTS-Kiste selbst: backend=hal-styletts2
gegen backend=piper, und ein grep -c 'piper fallback', das null ergibt.
Letzte sieben Tage bei mir: 157 Renders mit backend=hal-styletts2, kein
einziger Fallback.
Falle 5: “der Laptop ist offline” (ist er nicht)
Der Laptop verschluckt ICMP und lehnt von außen jeden TCP-Port ab — Windows
Defender Firewall, Profil “Öffentlich”. ping: 100 % Verlust. nmap -p-:
findet nichts. Gleichzeitig war eine SSH-Sitzung in die WSL offen und hat
tadellos funktioniert.
Aus einem Scan zu schließen, dass eine Maschine offline ist, ist bei Windows
schlicht falsch. Teste mit dem echten ssh-Kommando, nie mit einem Ping oder
einem Portscan.
Dazu die zweite Hälfte desselben Problems: Das WLAN war als Öffentlich eingestuft. Eine Firewall-Regel, die auf “Privat” gilt, greift dann nie — und zwar still. Sie steht in der Liste, sie ist aktiv, sie tut nichts.
WSL läuft hier mit networkingMode=mirrored. Das heißt, ein WSL-Dienst, der auf
0.0.0.0 lauscht, teilt sich die LAN-IP des Windows-Hosts. Kein netsh portproxy nötig, nur die Firewall-Regel. In einer erhöhten PowerShell:
New-NetFirewallRule -DisplayName "WSL voice services (LAN)" `
-Direction Inbound -Action Allow -Protocol TCP `
-LocalPort 10300,11436 -RemoteAddress 192.168.1.0/24 -Profile Any
powercfg /change standby-timeout-ac 0
Set-NetConnectionProfile -InterfaceAlias "Wi-Fi" -NetworkCategory Private
Praktischer Tipp: Bevor du irgendetwas Schweres installierst, teste den Port mit einem Wegwerf-Listener:
python3 -m http.server 10300 --bind 0.0.0.0
Wenn der von außen nicht erreichbar ist, liegt es nicht an deinem Modell.
Und eine Regel zum Eintätowieren: Niemals aus der Ferne
wsl.exe --terminate aufrufen. Das killt den sshd mit — also genau den Weg,
über den du gerade drin bist. Die Wiederherstellung heißt dann: zum Laptop
gehen. Einzelne Dienste per systemctl --user restart neu starten, sonst nichts.
Was jetzt tatsächlich läuft
Zwei systemctl --user-Dienste mit aktiviertem Linger, starten also ohne
SSH-Sitzung. Beide seit dem Reboot am 17. September durchgehend oben, also über
sieben Tage:
| Dienst | Port | Was |
|---|---|---|
wyoming-stt.service |
10300 | faster-whisper large-v3-turbo, cuda/float16, --language auto, --idle-unload 0 |
hal-tts.service |
11436 | HAL-Stimme, StyleTTS2, --preload, 0,84 GiB auf cuda |
idle_unload=0 bei beiden — dauerhaft resident. Das ist der ganze Punkt des
Umzugs. Nichts wirft mehr etwas raus.
Der echte ExecStart der STT-Unit, inklusive des Skriptnamens, der aus dem
gemma-Umweg von Teil 1 übrig ist (die Unit selbst trägt inzwischen einen
Kommentar, der zugibt, dass der Name lügt):
ExecStart=%h/.hermes/venv-wyoming/bin/python %h/.hermes/bin/wyoming-gemma-stt.py --uri tcp://0.0.0.0:10300 --model large-v3-turbo --language auto --idle-unload 0
Auf dem Server bleibt der kleine Shim auf Port 11435, mit dem die Sprach-Pipeline redet. Er zeigt jetzt einfach woandershin:
ExecStart=%h/.hermes/bin/ha-hermes-ollama-shim.py --host 0.0.0.0 --port 11435 --hermes-url http://127.0.0.1:8643 --stt-host 192.168.1.205 --tts-host 192.168.1.205
Zwei Flags. Das ist der gesamte Umbau auf der Serverseite. Der Rest war Installationsarbeit auf dem Laptop.
Und die 2080 Ti? Die gehört wieder dem Chatbot. Verifiziert mit 43 von 43 Layern auf der GPU. Ollama lädt und entlädt ihn weiterhin nach Bedarf, das ist normal und war nie das Problem — das Problem war der zweite Mitbewohner.
Die Zahlen, und die eine, die schlechter wurde
Kompletter Sprach-Turn über den /voice-Endpunkt, gemessen bis zum ersten Audio:
| kalt | warm | |
|---|---|---|
| vorher (alles auf dem Server) | 4,97 s | 1,58 s |
| nachher (STT+TTS auf dem Laptop) | 2,29 s | 1,81 s |
Kalt mehr als halbiert, weil nichts mehr rausgeworfen und nachgeladen wird. Warm rund 0,2 s schlechter — das ist der LAN-Hop, und den bekomme ich nicht weg. Die Latenz Server → Laptop liegt bei 18 ms Median über sechs Messungen.
Ich schreibe das hin, weil es die ehrliche Bilanz ist: Der Umzug hat den schlechtesten Fall stark verbessert und den besten Fall leicht verschlechtert. Eine Viertelsekunde tausche ich gern dafür, nie wieder fünf Sekunden zu warten.
Nach dem echten Reboot am 17. September sah der Hochlauf so aus:
20:21:33 WSL oben
20:21:39 wyoming-stt
20:21:53 hal-tts
erster kompletter Sprach-Turn: 2,02 s, HAL-Stimme
Der erste Sprach-Turn nach jedem Neustart dauert allerdings 6–7 Sekunden — Lazy Load. Einmalig, und trotzdem genau der Moment, in dem man denkt, es sei kaputt.
Und ja: Der TTS-Rückweg aus Teil 1 existiert damit. Der Sprach-Turn antwortet gesprochen statt nur zu schreiben. Wie das vorne am Mikrofon ankommt, ist ein eigener Artikel und hier bewusst nicht Thema.
Ehrlich: was an diesem Aufbau wackelt
Der Reboot braucht eine Anmeldung. Ein VBS-Skript im Windows-Autostart startet WSL versteckt — aber beim Logon, nicht beim Boot. Danach bringt systemd-Linger beide Dienste hoch. Heißt: Wenn Windows sich um 3 Uhr früh ein Update abholt und neu startet, ist der Sprachassistent tot, bis sich jemand anmeldet. Ich habe das vorerst akzeptiert. Die saubere Lösung wäre eine geplante Aufgabe “AtStartup” als SYSTEM — die braucht Adminrechte und ist bei mir ungetestet. Sie könnte eine zweite WSL-Instanz starten, die auf denselben Ports kollidiert. Ich behaupte also nicht, dass sie funktioniert.
Es ist ein Laptop. Schlafen am Netzteil ist deaktiviert, auf Akku geht er nach zwei Stunden trotzdem schlafen, und Deckel zu heißt: Sprachassistent weg. Das Ding ist früher gereist. Jetzt bleibt es daheim — aus Gewohnheit, nicht aus Garantie.
Zwei Maschinen sind zwei Ausfallpunkte. Der Sprachassistent hängt jetzt an einem Windows-Laptop. Das ist keine Architekturentscheidung, das ist eine Verfügbarkeitsentscheidung, und sie ist schlechter als vorher.
Warm ist langsamer geworden. 1,58 s → 1,81 s. Steht oben, steht auch hier.
WSL hat 7 GB RAM. Für größere Modelle dünn, auch wenn das VRAM reichen würde.
Rollback existiert. Die alten Units auf dem Server (hal-tts.service und
wyoming-gemma-stt.service) sind gestoppt und deaktiviert, aber noch
installiert. Zwei Flags am Shim weg, alte Units wieder aktivieren, fertig.
Und der wichtigste Punkt, der Arbeit spart: Die meisten Leser brauchen davon nichts. Wenn du eine GPU hast und keine konkurrierende Live-Last darauf, dann lass Whisper einfach auf der GPU laufen und geh nach Hause. Dieser ganze Aufbau existiert, weil ein Hochzeits-Chatbot auf meiner Karte sitzt und da nicht runter darf.
Die Prüfliste, die ich mir aus den fünf Fallen gebaut habe
Nur eine der fünf Fallen ist tatsächlich abgestürzt. Der Rest hatte eines gemeinsam: kein roter Balken, kein Alarm. Es lief nur falsch, oder sah tot aus, obwohl es lebte. Genau dafür gibt es jetzt eine Liste, die ich nach jedem Eingriff durchgehe:
- Läuft es wirklich auf der GPU? Journal nach
on cudagegenon cpudurchsuchen. Eine Transkription, die zwanzigmal länger dauert als nötig, sieht in Home Assistant aus wie “heute halt langsam”. - Rendert wirklich die Stimme, die ich glaube? Nicht im Shim nachsehen, der
lügt per Konstante. Auf der TTS-Kiste nach
backend=suchen und zusätzlichgrep -c 'piper fallback'laufen lassen. Alles außer null ist ein Befund. - Ist es unerwartet schnell geworden? Dann nicht freuen, sondern nachsehen. Bei mir war “schneller” jedes Mal ein Fallback, nie eine Verbesserung.
- Neuer Port? Erst mit
python3 -m http.serverprüfen, dann installieren. - “Nicht erreichbar” ist bei Windows keine Aussage. Vor der Fehlersuche im
Dienst klären, ob überhaupt jemals ein Paket ankommen könnte — Profil
Öffentlich gegen Privat, und
pingbeweist hier gar nichts.
Die Liste ist nicht klug. Sie ist nur die Antwort darauf, dass mich dieser Aufbau öfter angelogen hat, als er ausgefallen ist.
Wo ich gelandet bin
Ich habe in Teil 1 geschrieben, man solle lieber das num_ctx des Platzhirschs
senken, als ihn abzuschießen. Das war richtig gedacht und trotzdem der falsche
Weg — weil ich zwei Optionen verglichen habe, die beide davon ausgingen, dass es
genau eine Karte gibt. Die Frage “welches Modell muss weichen?” war von Anfang
an die falsche Frage.
Die Antwort war ein Laptop, der früher gereist ist und jetzt einen Tagesjob hat. Nicht elegant, nicht geplant, ziemlich genau das Gegenteil von Architektur. Aber der Chatbot hat seine Karte, Whisper und die Stimme haben ihre, und nichts wirft mehr etwas raus.
Und mir ist dabei noch etwas klargeworden: Der ganze Umzug war möglich, weil jeder Baustein in diesem Aufbau ein Host und ein Port ist. Zwei Flags am Shim, und die halbe Sprachkette wohnt auf einer anderen Maschine. Bei jedem Cloud-Dienst, den ich stattdessen hätte nehmen können, wäre diese Frage gar nicht gestellt worden — da zieht nichts um, da kündigt man.