diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b887ba0..d7c00a4 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -136,8 +136,7 @@ geklonten echten Personen, siehe [`CLAUDE.md`](CLAUDE.md)). 2. Datei nach `services/voices/.wav` legen (z. B. `services/voices/elena.wav`). 3. TTS-Server neu starten (Stimmen werden nur beim Start eingelesen): ```bash - pkill -f "python tts_server.py" - ./scripts/start_services.sh + systemctl --user restart open-notebook-tts ``` 4. Prüfen: `curl http://127.0.0.1:8901/health` sollte `` in der `voices`-Liste zeigen. 5. In einem Sprecherprofil (Podcast) den betreffenden Sprecher auf `voice_id: ""` setzen, @@ -207,12 +206,17 @@ Weitere OpenRouter-Modelle lassen sich jederzeit über `POST /api/models` regist docker compose up -d docker compose down -# TTS/STT (lokale Hintergrundprozesse, nicht in Docker) -./scripts/start_services.sh -pkill -f "python tts_server.py" -pkill -f "python stt_server.py" +# TTS/STT (auf dem Host, nicht in Docker — systemd-User-Dienste) +systemctl --user status open-notebook-tts open-notebook-stt +systemctl --user restart open-notebook-tts open-notebook-stt +systemctl --user stop open-notebook-tts open-notebook-stt +journalctl --user -u open-notebook-tts -f # Logs ``` +TTS/STT starten nach einem Reboot **automatisch** mit. Nur bei der Ersteinrichtung (oder nach +Änderungen an `services/systemd/*.service`) einmal `./scripts/start_services.sh` ausführen — das +installiert die Unit-Dateien und aktiviert den Autostart. + Ollama selbst läuft als systemd-Dienst und muss normalerweise nicht manuell verwaltet werden: ```bash @@ -368,8 +372,8 @@ Such-/Ask-Funktion oder „nur Erkenntnisse" nach einer Zusammenfassung nutzen. docker compose exec open_notebook curl -sf http://host.docker.internal:8901/health ``` -Bei Timeout: `ufw`-Regeln fehlen (siehe README 2.4) oder die Server laufen nicht -(`./scripts/start_services.sh`). +Bei Timeout: `ufw`-Regeln fehlen (siehe README 2.4) oder die Dienste laufen nicht +(`systemctl --user status open-notebook-tts open-notebook-stt`). ### Podcast-Profil zeigt „Einrichtung erforderlich" @@ -410,6 +414,32 @@ wird abgeschnitten. Behoben durch `/no_think` als erste Zeile beider Podcast-Vor den Denkmodus ab, ~3× schneller, volles Budget fürs JSON). Falls es dennoch auftritt: kürzeren Quellinhalt verwenden (nicht das ganze Buch) oder die Generierung erneut starten. +### Podcast schlägt beim Vertonen fehl („Failed to generate speech") + +Text und Transkript sind fertig, erst die Sprachausgabe scheitert. Die genaue Fehlermeldung +(UI-Episodenliste oder `docker compose logs open_notebook`) unterscheidet **zwei verschiedene +Ursachen** — die Verwechslung kostet sonst viel Zeit: + +| Meldung | Bedeutung | Abhilfe | +|---|---|---| +| `All connection attempts failed` (`ConnectError`) | Der TTS-Server **läuft nicht** — niemand nimmt den Request an. | `systemctl --user status open-notebook-tts`, ggf. `restart`. | +| `OpenAI-compatible TTS endpoint error: HTTP 500` | Der TTS-Server **lebt**, bricht aber bei der Arbeit ab — meist `CUDA out of memory`. | `journalctl --user -u open-notebook-tts -n 50` prüfen. | + +Der GPU-Speicherfall entstand so: GPU 2 teilen sich drei Prozesse (TTS, STT und der separate +`chatterbox-tts`-MCP-Dienst auf Port 9999), es bleiben nur ~12 GB übrig. Der Podcast-Generator +schickte aber 5 Clips gleichzeitig, die im TTS-Server echt parallel auf derselben GPU liefen — +der Speicherbedarf vervielfachte sich (bis 16,7 GB) und sprengte die Karte. Behoben durch zwei +Maßnahmen: `tts_server.py` serialisiert seine Generierung intern (ein Clip zur Zeit — auf einer +einzelnen GPU kostet das keinen Durchsatz), und `TTS_BATCH_SIZE=1` in `docker-compose.yml` sorgt +dafür, dass gar nicht erst mehrere Anfragen gleichzeitig eintreffen. Ein fehlgeschlagener Podcast +lässt sich anschließend ohne Neuanlage wiederholen: + +```bash +curl -X POST http://127.0.0.1:5055/api/podcasts/episodes/{episode_id}/retry +``` + +(Der Retry legt einen neuen Episoden-Eintrag an; der alte, fehlgeschlagene verschwindet.) + ### Weitere technische Details Siehe [`CLAUDE.md`](CLAUDE.md) — dort stehen alle bisher aufgetretenen Probleme mit exakter diff --git a/CLAUDE.md b/CLAUDE.md index 53af565..576d082 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,15 +81,61 @@ Open Notebook's `openai_compatible` provider type talks to any endpoint implemen - `services/tts_server.py` — wraps `chatterbox-tts` (via `~/chatterbox-tts-cli/chatterbox_cli_v4.py`), run with the `chatterbox` conda env (`~/miniforge3/envs/chatterbox/bin/python`). - `services/stt_server.py` — wraps `faster-whisper` (`large-v3` model), run with the base `python3`. -Both are plain background processes (`nohup ... &`), **not** systemd units — they don't survive a reboot; restart manually if needed: +Both run as **systemd user units**. The unit files live in the repo (`services/systemd/*.service`, +using `%h` rather than a hardcoded home) and `scripts/start_services.sh` installs them into +`~/.config/systemd/user/`, enables `linger` and starts them — so they come up on boot without a +login session. Re-run that script after editing a unit; it's idempotent. They log to the journal, +not to `services/logs/` (those files are leftovers from the earlier `nohup` setup): ```bash -cd ~/open-notebook/services -CUDA_VISIBLE_DEVICES=2 TTS_PORT=8901 nohup ~/miniforge3/envs/chatterbox/bin/python tts_server.py > logs/tts_server.log 2>&1 & -CUDA_VISIBLE_DEVICES=2 STT_PORT=8902 nohup python3 stt_server.py > logs/stt_server.log 2>&1 & +systemctl --user status open-notebook-tts open-notebook-stt +systemctl --user restart open-notebook-tts # after editing tts_server.py or adding voices +journalctl --user -u open-notebook-tts -f ``` -They're pinned to `CUDA_VISIBLE_DEVICES=2` deliberately — GPU 1 tends to fill up with Ollama's resident models (`OLLAMA_KEEP_ALIVE=-1` keeps them loaded), so TTS/STT get GPU 2 to themselves rather than risking an OOM race with Ollama. +They were previously plain `nohup ... &` background processes, which silently did not survive a +reboot — a podcast then failed at the audio stage with `Failed to generate speech: All connection +attempts failed` (`httpx.ConnectError` against `host.docker.internal:8901`) even though outline +and transcript had generated fine. That's the signature of a dead TTS server, not a model problem. + +Both units pin GPU 2 **by UUID** (`CUDA_VISIBLE_DEVICES=GPU-83ba6d1f-…`), not by index: GPU 1 is +occupied by Ollama's resident models (`OLLAMA_KEEP_ALIVE=-1` keeps them loaded), so TTS/STT go to +GPU 2 rather than racing Ollama for VRAM. The index form (`CUDA_VISIBLE_DEVICES=2`) is **not** +reliable here — CUDA's default device order is "fastest first", not nvidia-smi's PCI order, so +index 2 can resolve to the T600. Re-check the UUID with +`nvidia-smi --query-gpu=index,name,uuid --format=csv` if the GPUs are ever reseated. + +#### GPU 2 is shared — TTS concurrency is capped on purpose + +GPU 2 does **not** belong to the TTS server alone. Three processes sit on it: `open-notebook-tts` +(~4 GB idle-loaded), `open-notebook-stt` (faster-whisper `large-v3`, ~4 GB), and the unrelated +`chatterbox-tts.service` MCP server on port 9999 (~3.4 GB). That leaves roughly 12 GB of working +headroom, and TTS inference must fit inside it. + +`podcast_creator` requests `TTS_BATCH_SIZE` clips **concurrently** (`asyncio.gather` per batch, +`nodes.py`; upstream default **5**). `tts_server.py`'s `/audio/speech` is a *sync* FastAPI handler, +so FastAPI runs each request in a threadpool thread — five clips therefore generate genuinely in +parallel on one shared model, and the activation memory multiplies. That drove the TTS process to +**16.7 GB** and produced `torch.OutOfMemoryError` inside chatterbox's `s3gen.embed_ref`, surfacing +in the app as `Failed to generate speech: OpenAI-compatible TTS endpoint error: HTTP 500`. Note +this is a *different* failure from the dead-server `ConnectError` above — a 500 means the server is +alive and rejecting the work, so check the TTS journal for the CUDA OOM before assuming a restart +will help. + +Two changes keep it bounded, and both matter: + +- **`tts_server.py` serializes generation behind `_GPU_LOCK`** (a `threading.Lock` around model load + + the chunk loop, plus `torch.cuda.empty_cache()` afterwards). Generation is GPU-bound, so running + clips in parallel buys no throughput on a single card — it only multiplies peak VRAM. This is the + hard guarantee: it holds no matter which client calls, and no matter what batch size they use. +- **`TTS_BATCH_SIZE=1` in `docker-compose.yml`** so the client sends clips one at a time. Without + this, the lock still prevents the OOM, but 4 of the 5 batched requests sit queued in it — and with + ~40-60 s per clip the last one approaches esperanto's **300 s** TTS timeout. `ESPERANTO_TTS_TIMEOUT=600` + is set alongside it as extra headroom for long segments. + +Measured after the fix: 5 concurrent `/audio/speech` requests all return 200, GPU 2 peaks at +~11.5 GB total (vs. 16.7 GB for the TTS process alone before), i.e. ~12 GB of headroom left. +If you add another GPU-resident service to GPU 2, re-check that budget. Reachability from the `open_notebook` container requires **two things**, both already done: the `extra_hosts: host.docker.internal:host-gateway` entry in `docker-compose.yml`, and UFW rules allowing inbound `8901/tcp` and `8902/tcp` (same pattern as the existing `11434/tcp` rule for Ollama — UFW defaults to deny-incoming, so any new host-side port a container needs to reach must be explicitly opened). @@ -107,7 +153,7 @@ Three reference clips exist in `services/voices/` (gitignored, not committed): piper --model de_DE-thorsten-medium.onnx --output_file male_thorsten.wav <<< "some German sentence" ``` -Podcast speaker profiles are assigned across these three: `business_panel` (Marcus→male_thorsten, Elena→female_kerstin, Johny→default), `tech_experts` (Alex→male_thorsten, Jamie→female_kerstin), `solo_expert`/`test_speaker_local` (→female_kerstin/Anna). To add another distinct voice, drop a new `.wav` (~10-30s, clean single-speaker audio, licensing-checked) into `services/voices/`, restart `tts_server.py` (voices are scanned once at startup), and set the speaker's `voice_id` to `` via `PUT /api/speaker-profiles/{id}`. +Podcast speaker profiles are assigned across these three: `business_panel` (Marcus→male_thorsten, Elena→female_kerstin, Johny→default), `tech_experts` (Alex→male_thorsten, Jamie→female_kerstin), `solo_expert`/`test_speaker_local` (→female_kerstin/Anna). To add another distinct voice, drop a new `.wav` (~10-30s, clean single-speaker audio, licensing-checked) into `services/voices/`, restart the TTS server (`systemctl --user restart open-notebook-tts` — voices are scanned once at startup), and set the speaker's `voice_id` to `` via `PUT /api/speaker-profiles/{id}`. ## Secrets diff --git a/README.md b/README.md index 5f61c96..6de42a5 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ Weiterentwicklung: siehe [`CLAUDE.md`](CLAUDE.md). | `surrealdb` | Docker | Datenbank (nur `127.0.0.1:8000`) | | `open_notebook` | Docker | Web-UI (`:8502`) + REST-API (`:5055`) | | Ollama | Host (systemd) | Chat-/Embedding-Modelle, GPU 1+2 | -| `services/tts_server.py` | Host (Hintergrundprozess) | Chatterbox TTS, GPU 2, Port `8901` | -| `services/stt_server.py` | Host (Hintergrundprozess) | faster-whisper STT, GPU 2, Port `8902` | +| `services/tts_server.py` | Host (systemd-User-Dienst) | Chatterbox TTS, GPU 2, Port `8901` | +| `services/stt_server.py` | Host (systemd-User-Dienst) | faster-whisper STT, GPU 2, Port `8902` | Der `open_notebook`-Container erreicht Ollama und die TTS/STT-Wrapper über `host.docker.internal` (Linux-Route via `extra_hosts: host-gateway`). @@ -101,15 +101,25 @@ sudo ufw allow 8902/tcp comment 'open-notebook STT (faster-whisper)' Ohne diesen Schritt bleiben TTS/STT vom Container aus unerreichbar (Timeout), obwohl sie lokal laufen und funktionieren. -### 2.5 TTS/STT-Server starten +### 2.5 TTS/STT-Server einrichten ```bash ./scripts/start_services.sh ``` -Startet `tts_server.py` und `stt_server.py` als Hintergrundprozesse auf GPU 2 (nicht GPU 0/1, -um Ollama und die Desktop-GPU nicht zu belasten). Nicht systemd-verwaltet — nach einem Reboot -erneut ausführen. Logs: `services/logs/`. +Installiert `services/systemd/*.service` nach `~/.config/systemd/user/`, aktiviert `linger` +(damit die Dienste auch ohne aktive Login-Session laufen) und startet beide. Sie laufen auf +GPU 2 — nicht GPU 0/1, um die Desktop-GPU und Ollamas Chat-Modell nicht zu verdrängen. + +Das Skript ist idempotent und **überlebt Reboots**: die Dienste starten automatisch mit. Status +und Logs: + +```bash +systemctl --user status open-notebook-tts open-notebook-stt +journalctl --user -u open-notebook-tts -f +``` + +> Der erste Start dauert bis zu einer Minute, weil whisper `large-v3` geladen wird. Für Stimmklonung mindestens eine Referenz-WAV in `services/voices/default.wav` ablegen (z. B. Symlink auf eine eigene Sprachaufnahme, 10–30s, WAV) — siehe @@ -163,9 +173,16 @@ docker compose up -d # starten docker compose down # stoppen (Daten bleiben erhalten) docker compose logs -f open_notebook # Logs verfolgen docker compose ps # Status -./scripts/start_services.sh # TTS/STT (re-)starten, z.B. nach Reboot + +systemctl --user status open-notebook-tts open-notebook-stt # TTS/STT-Status +systemctl --user restart open-notebook-tts # z.B. nach neuer Stimme +journalctl --user -u open-notebook-tts -f # TTS-Logs +./scripts/start_services.sh # Units neu installieren + starten (idempotent) ``` +TTS/STT starten nach einem Reboot von selbst — `start_services.sh` ist nur für die +Ersteinrichtung bzw. nach Änderungen an den Unit-Dateien nötig. + --- ## 4. Verzeichnisstruktur @@ -180,11 +197,12 @@ open-notebook/ ├── services/ │ ├── tts_server.py # Chatterbox-Wrapper (OpenAI-API-kompatibel) │ ├── stt_server.py # faster-whisper-Wrapper +│ ├── systemd/ # Unit-Dateien für die beiden Wrapper │ ├── voices/ # Referenz-WAVs fürs Voice-Cloning (gitignored) -│ └── logs/ # (gitignored) +│ └── logs/ # (gitignored, Altlast der früheren nohup-Variante) ├── scripts/ │ ├── setup_models.sh # Provider/Modelle einrichten (idempotent) -│ └── start_services.sh # TTS/STT starten +│ └── start_services.sh # TTS/STT als systemd-User-Dienste installieren+starten ├── README.md # diese Datei ├── BEDIENUNGSANLEITUNG.md # ausführliche Bedienungsanleitung (Deutsch) └── CLAUDE.md # technische Architektur-Doku für Weiterentwicklung @@ -204,6 +222,11 @@ Kurzreferenz — Details jeweils in [`CLAUDE.md`](CLAUDE.md): leere Chat-Antworten bei größerem Kontext, ohne sichtbaren Fehler. - **Neue Host-Ports brauchen eine `ufw`-Regel**, sonst kann der Container sie nicht erreichen (Timeout, keine Fehlermeldung in Open Notebook selbst). +- **GPU 2 teilen sich drei Prozesse** (TTS, STT und der separate `chatterbox-tts`-MCP-Dienst auf + Port 9999) — es bleiben nur ~12 GB Arbeitsspeicher für die Sprachsynthese. Deshalb serialisiert + `tts_server.py` seine Generierung intern und `TTS_BATCH_SIZE=1` steht in `docker-compose.yml`: + ohne beides schickt der Podcast-Generator 5 Clips gleichzeitig, die parallel auf der GPU laufen + und sie mit `CUDA out of memory` sprengen (sichtbar als `HTTP 500` beim Vertonen). - **Podcast-Sprache** wird über das `language`-Feld des Episode-Profils gesteuert, aber nur dank der gepatchten Prompt-Vorlagen unter `prompts/podcast/` (per Bind-Mount in `docker-compose.yml` eingehängt) — die Original-Vorlagen im Image ignorieren `language`, was diff --git a/docker-compose.yml b/docker-compose.yml index 97ea4a9..d09f4a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,14 @@ services: - SURREAL_DATABASE=open_notebook - OLLAMA_BASE_URL=http://host.docker.internal:11434 - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + # podcast_creator schickt sonst 5 Audio-Clips gleichzeitig an den TTS-Server. + # Die laufen dort echt parallel auf einer GPU -> Aktivierungsspeicher + # vervielfacht sich -> CUDA OOM (GPU 2 teilt sich TTS, STT und den + # chatterbox-MCP-Dienst). Der TTS-Server serialisiert intern zwar ohnehin, + # aber bei Batch 5 warten 4 Requests im Lock und laufen ins 300s-Timeout. + - TTS_BATCH_SIZE=1 + # Kopfraum fuer lange Segmente; Default waere 300s pro Clip. + - ESPERANTO_TTS_TIMEOUT=600 extra_hosts: - "host.docker.internal:host-gateway" volumes: diff --git a/scripts/start_services.sh b/scripts/start_services.sh index f1030c5..4205943 100755 --- a/scripts/start_services.sh +++ b/scripts/start_services.sh @@ -1,31 +1,57 @@ #!/usr/bin/env bash -# Startet die lokalen TTS/STT-Wrapper-Server (services/tts_server.py, -# services/stt_server.py) als Hintergrundprozesse auf GPU 2. Überspringt -# einen Service, falls er unter seinem Port bereits antwortet. +# Installiert und startet die lokalen TTS/STT-Wrapper-Server (services/tts_server.py, +# services/stt_server.py) als systemd-User-Dienste auf GPU 2. +# +# Frueher liefen die beiden als nohup-Hintergrundprozesse — die ueberleben keinen +# Reboot, und ein Podcast scheitert dann in der Vertonungsphase mit +# "Failed to generate speech: All connection attempts failed". Als systemd-Units +# starten sie automatisch mit (dank enable-linger auch ohne Login). +# +# Idempotent: mehrfach ausfuehrbar, aktualisiert die Units und startet sie neu. set -euo pipefail -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../services" && pwd)" -mkdir -p "$DIR/logs" -cd "$DIR" +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UNIT_SRC="$REPO/services/systemd" +UNIT_DST="$HOME/.config/systemd/user" +UNITS=(open-notebook-tts.service open-notebook-stt.service) -start_if_needed() { - local name="$1" port="$2" cmd="$3" - if curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then - echo "$name läuft bereits auf Port $port" - return - fi - echo "Starte $name auf Port $port..." - eval "$cmd" - disown - for _ in $(seq 1 15); do - curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && { echo "$name ist bereit."; return; } - sleep 2 +mkdir -p "$UNIT_DST" +for unit in "${UNITS[@]}"; do + install -m 644 "$UNIT_SRC/$unit" "$UNIT_DST/$unit" +done +systemctl --user daemon-reload + +# Ohne Linger laufen User-Dienste nur waehrend einer aktiven Login-Session und +# starten nicht beim Boot. +if [ "$(loginctl show-user "$USER" --property=Linger --value 2>/dev/null)" != "yes" ]; then + echo "Aktiviere linger (Dienste sollen auch ohne Login laufen)..." + loginctl enable-linger "$USER" +fi + +echo "Starte TTS/STT-Dienste..." +systemctl --user enable --quiet "${UNITS[@]}" +systemctl --user restart "${UNITS[@]}" + +# Modelle werden beim Start geladen (whisper large-v3 braucht ~1 Minute). +wait_healthy() { + local name="$1" port="$2" + for _ in $(seq 1 45); do + if curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then + echo " $name (Port $port) ist bereit." + return 0 + fi + sleep 4 done - echo "WARNUNG: $name antwortet nach 30s nicht auf /health — siehe logs/${name}.log" >&2 + echo "WARNUNG: $name antwortet nach 180s nicht auf /health." >&2 + echo " Logs: journalctl --user -u open-notebook-${name} -n 50" >&2 + return 1 } -start_if_needed "tts" 8901 \ - 'CUDA_VISIBLE_DEVICES=2 TTS_PORT=8901 nohup ~/miniforge3/envs/chatterbox/bin/python tts_server.py > logs/tts_server.log 2>&1 &' +rc=0 +wait_healthy tts 8901 || rc=1 +wait_healthy stt 8902 || rc=1 -start_if_needed "stt" 8902 \ - 'CUDA_VISIBLE_DEVICES=2 STT_PORT=8902 nohup python3 stt_server.py > logs/stt_server.log 2>&1 &' +if [ "$rc" -eq 0 ]; then + echo "Fertig. Status: systemctl --user status ${UNITS[*]}" +fi +exit "$rc" diff --git a/services/systemd/open-notebook-stt.service b/services/systemd/open-notebook-stt.service new file mode 100644 index 0000000..30b31d8 --- /dev/null +++ b/services/systemd/open-notebook-stt.service @@ -0,0 +1,21 @@ +[Unit] +Description=Open Notebook STT (faster-whisper, OpenAI-compatible wrapper on :8902) +After=network.target + +[Service] +Type=simple +WorkingDirectory=%h/open-notebook/services +ExecStart=%h/miniforge3/bin/python3 stt_server.py +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +Environment="STT_HOST=0.0.0.0" +Environment="STT_PORT=8902" +# GPU 2 eindeutig per UUID, NICHT per Index — siehe Kommentar in +# open-notebook-tts.service. +Environment="CUDA_VISIBLE_DEVICES=GPU-83ba6d1f-ebbe-f8c8-1433-153e0ebc3866" +Environment="PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True" + +[Install] +WantedBy=default.target diff --git a/services/systemd/open-notebook-tts.service b/services/systemd/open-notebook-tts.service new file mode 100644 index 0000000..4ed7d9a --- /dev/null +++ b/services/systemd/open-notebook-tts.service @@ -0,0 +1,23 @@ +[Unit] +Description=Open Notebook TTS (chatterbox, OpenAI-compatible wrapper on :8901) +After=network.target + +[Service] +Type=simple +WorkingDirectory=%h/open-notebook/services +ExecStart=%h/miniforge3/envs/chatterbox/bin/python tts_server.py +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +Environment="TTS_HOST=0.0.0.0" +Environment="TTS_PORT=8901" +# GPU 2 eindeutig per UUID, NICHT per Index: CUDA sortiert standardmaessig +# "fastest first" statt nach PCI-Reihenfolge, dadurch kann Index 2 auf der +# T600 landen. UUID pruefen mit: +# nvidia-smi --query-gpu=index,name,uuid --format=csv +Environment="CUDA_VISIBLE_DEVICES=GPU-83ba6d1f-ebbe-f8c8-1433-153e0ebc3866" +Environment="PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True" + +[Install] +WantedBy=default.target diff --git a/services/tts_server.py b/services/tts_server.py index b54340a..59795b1 100644 --- a/services/tts_server.py +++ b/services/tts_server.py @@ -25,6 +25,7 @@ import os import subprocess import sys import tempfile +import threading from pathlib import Path sys.path.insert(0, str(Path.home() / "chatterbox-tts-cli")) @@ -40,6 +41,14 @@ app = FastAPI(title="Chatterbox TTS (OpenAI-compatible, voice cloning)", version _DEVICE = tts.get_device(None) _model_cache: dict[str, tuple] = {} +# Serializes model load + inference. FastAPI runs sync handlers in a threadpool, so +# without this every concurrent request generates in parallel on the same GPU and the +# activation memory multiplies — podcast_creator sends TTS_BATCH_SIZE (default 5) clips +# at once, which OOM'd this server (peak 16.7 GB) since GPU 2 is shared with the STT +# server and the chatterbox MCP service. Generation is GPU-bound, so serializing costs +# no real throughput; it just bounds peak VRAM to a single clip. +_GPU_LOCK = threading.Lock() + VOICES_DIR = Path(__file__).parent / "voices" CLONE_LANG = "de" # language used for every cloned reference voice VOICE_LANG_OVERRIDES: dict[str, str] = {} # e.g. {"john": "en"} if you add an English clip @@ -124,12 +133,18 @@ def speech(req: SpeechRequest): if not chunks: raise HTTPException(status_code=422, detail="Kein synthetisierbarer Text übrig.") - model, model_kind, sr = _get_model(lang) - - wavs = [] - for chunk in chunks: - wavs.append(tts.generate_chunk(model, model_kind, chunk, lang, voice_path)) - final = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1) + with _GPU_LOCK: + model, model_kind, sr = _get_model(lang) + try: + wavs = [] + for chunk in chunks: + wavs.append(tts.generate_chunk(model, model_kind, chunk, lang, voice_path)) + final = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1) + finally: + # GPU 2 is shared; hand cached blocks back so a neighbour process can't be + # starved by fragmentation we're holding on to. + if torch.cuda.is_available(): + torch.cuda.empty_cache() with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wav_tmp: wav_path = wav_tmp.name