Fix two podcast failures: dead TTS server after reboot + CUDA OOM
Podcast generation failed twice at the audio stage, for two unrelated reasons that look similar from the UI but need opposite fixes. 1) TTS/STT ran as nohup background processes and silently did not survive a reboot. Podcasts then failed with "Failed to generate speech: All connection attempts failed" (httpx.ConnectError) even though outline and transcript had generated fine. Both now run as systemd user units. The unit files are versioned in services/systemd/ (using %h, not a hardcoded home) and installed by scripts/start_services.sh, which also enables linger so they start on boot without a login session. They pin GPU 2 by UUID, not by index: CUDA orders devices "fastest first", so index 2 can resolve to the T600. 2) GPU 2 is shared by three processes (TTS, STT and the separate chatterbox-tts MCP service on :9999), leaving ~12 GB of headroom. podcast_creator sends TTS_BATCH_SIZE (default 5) clips concurrently, and since /audio/speech is a sync FastAPI handler, they generated genuinely in parallel on one shared model. Activation memory multiplied, the TTS process hit 16.7 GB and threw torch.OutOfMemoryError, surfacing as "HTTP 500" from the endpoint. tts_server.py now serializes generation behind a lock (GPU-bound work, so parallelism buys no throughput — it only multiplies peak VRAM) and frees the cache afterwards. TTS_BATCH_SIZE=1 keeps the client from queuing requests in that lock and running into esperanto's 300s TTS timeout; ESPERANTO_TTS_TIMEOUT is raised to 600s as headroom. Verified: 5 concurrent /audio/speech requests all return 200 with GPU 2 peaking at ~11.5 GB (was 16.7 GB for the TTS process alone), and the previously failed episode now completes end to end — 38/38 batches, 10:56 min of audio, zero OOM. Docs record both failure signatures side by side, since ConnectError (server dead) and HTTP 500 (server alive, out of VRAM) have very different remedies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
054f867a8a
commit
03877588e1
8 changed files with 244 additions and 52 deletions
|
|
@ -136,8 +136,7 @@ geklonten echten Personen, siehe [`CLAUDE.md`](CLAUDE.md)).
|
||||||
2. Datei nach `services/voices/<name>.wav` legen (z. B. `services/voices/elena.wav`).
|
2. Datei nach `services/voices/<name>.wav` legen (z. B. `services/voices/elena.wav`).
|
||||||
3. TTS-Server neu starten (Stimmen werden nur beim Start eingelesen):
|
3. TTS-Server neu starten (Stimmen werden nur beim Start eingelesen):
|
||||||
```bash
|
```bash
|
||||||
pkill -f "python tts_server.py"
|
systemctl --user restart open-notebook-tts
|
||||||
./scripts/start_services.sh
|
|
||||||
```
|
```
|
||||||
4. Prüfen: `curl http://127.0.0.1:8901/health` sollte `<name>` in der `voices`-Liste zeigen.
|
4. Prüfen: `curl http://127.0.0.1:8901/health` sollte `<name>` in der `voices`-Liste zeigen.
|
||||||
5. In einem Sprecherprofil (Podcast) den betreffenden Sprecher auf `voice_id: "<name>"` setzen,
|
5. In einem Sprecherprofil (Podcast) den betreffenden Sprecher auf `voice_id: "<name>"` setzen,
|
||||||
|
|
@ -207,12 +206,17 @@ Weitere OpenRouter-Modelle lassen sich jederzeit über `POST /api/models` regist
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
# TTS/STT (lokale Hintergrundprozesse, nicht in Docker)
|
# TTS/STT (auf dem Host, nicht in Docker — systemd-User-Dienste)
|
||||||
./scripts/start_services.sh
|
systemctl --user status open-notebook-tts open-notebook-stt
|
||||||
pkill -f "python tts_server.py"
|
systemctl --user restart open-notebook-tts open-notebook-stt
|
||||||
pkill -f "python stt_server.py"
|
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:
|
Ollama selbst läuft als systemd-Dienst und muss normalerweise nicht manuell verwaltet werden:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
Bei Timeout: `ufw`-Regeln fehlen (siehe README 2.4) oder die Dienste laufen nicht
|
||||||
(`./scripts/start_services.sh`).
|
(`systemctl --user status open-notebook-tts open-notebook-stt`).
|
||||||
|
|
||||||
### Podcast-Profil zeigt „Einrichtung erforderlich"
|
### 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
|
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.
|
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
|
### Weitere technische Details
|
||||||
|
|
||||||
Siehe [`CLAUDE.md`](CLAUDE.md) — dort stehen alle bisher aufgetretenen Probleme mit exakter
|
Siehe [`CLAUDE.md`](CLAUDE.md) — dort stehen alle bisher aufgetretenen Probleme mit exakter
|
||||||
|
|
|
||||||
58
CLAUDE.md
58
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/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`.
|
- `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
|
```bash
|
||||||
cd ~/open-notebook/services
|
systemctl --user status open-notebook-tts open-notebook-stt
|
||||||
CUDA_VISIBLE_DEVICES=2 TTS_PORT=8901 nohup ~/miniforge3/envs/chatterbox/bin/python tts_server.py > logs/tts_server.log 2>&1 &
|
systemctl --user restart open-notebook-tts # after editing tts_server.py or adding voices
|
||||||
CUDA_VISIBLE_DEVICES=2 STT_PORT=8902 nohup python3 stt_server.py > logs/stt_server.log 2>&1 &
|
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).
|
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"
|
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 `<name>.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 `<name>` 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 `<name>.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 `<name>` via `PUT /api/speaker-profiles/{id}`.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
|
|
|
||||||
41
README.md
41
README.md
|
|
@ -24,8 +24,8 @@ Weiterentwicklung: siehe [`CLAUDE.md`](CLAUDE.md).
|
||||||
| `surrealdb` | Docker | Datenbank (nur `127.0.0.1:8000`) |
|
| `surrealdb` | Docker | Datenbank (nur `127.0.0.1:8000`) |
|
||||||
| `open_notebook` | Docker | Web-UI (`:8502`) + REST-API (`:5055`) |
|
| `open_notebook` | Docker | Web-UI (`:8502`) + REST-API (`:5055`) |
|
||||||
| Ollama | Host (systemd) | Chat-/Embedding-Modelle, GPU 1+2 |
|
| Ollama | Host (systemd) | Chat-/Embedding-Modelle, GPU 1+2 |
|
||||||
| `services/tts_server.py` | Host (Hintergrundprozess) | Chatterbox TTS, GPU 2, Port `8901` |
|
| `services/tts_server.py` | Host (systemd-User-Dienst) | Chatterbox TTS, GPU 2, Port `8901` |
|
||||||
| `services/stt_server.py` | Host (Hintergrundprozess) | faster-whisper STT, GPU 2, Port `8902` |
|
| `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
|
Der `open_notebook`-Container erreicht Ollama und die TTS/STT-Wrapper über
|
||||||
`host.docker.internal` (Linux-Route via `extra_hosts: host-gateway`).
|
`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
|
Ohne diesen Schritt bleiben TTS/STT vom Container aus unerreichbar (Timeout), obwohl sie lokal
|
||||||
laufen und funktionieren.
|
laufen und funktionieren.
|
||||||
|
|
||||||
### 2.5 TTS/STT-Server starten
|
### 2.5 TTS/STT-Server einrichten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/start_services.sh
|
./scripts/start_services.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Startet `tts_server.py` und `stt_server.py` als Hintergrundprozesse auf GPU 2 (nicht GPU 0/1,
|
Installiert `services/systemd/*.service` nach `~/.config/systemd/user/`, aktiviert `linger`
|
||||||
um Ollama und die Desktop-GPU nicht zu belasten). Nicht systemd-verwaltet — nach einem Reboot
|
(damit die Dienste auch ohne aktive Login-Session laufen) und startet beide. Sie laufen auf
|
||||||
erneut ausführen. Logs: `services/logs/`.
|
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.
|
Für Stimmklonung mindestens eine Referenz-WAV in `services/voices/default.wav` ablegen (z. B.
|
||||||
Symlink auf eine eigene Sprachaufnahme, 10–30s, WAV) — siehe
|
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 down # stoppen (Daten bleiben erhalten)
|
||||||
docker compose logs -f open_notebook # Logs verfolgen
|
docker compose logs -f open_notebook # Logs verfolgen
|
||||||
docker compose ps # Status
|
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
|
## 4. Verzeichnisstruktur
|
||||||
|
|
@ -180,11 +197,12 @@ open-notebook/
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── tts_server.py # Chatterbox-Wrapper (OpenAI-API-kompatibel)
|
│ ├── tts_server.py # Chatterbox-Wrapper (OpenAI-API-kompatibel)
|
||||||
│ ├── stt_server.py # faster-whisper-Wrapper
|
│ ├── stt_server.py # faster-whisper-Wrapper
|
||||||
|
│ ├── systemd/ # Unit-Dateien für die beiden Wrapper
|
||||||
│ ├── voices/ # Referenz-WAVs fürs Voice-Cloning (gitignored)
|
│ ├── voices/ # Referenz-WAVs fürs Voice-Cloning (gitignored)
|
||||||
│ └── logs/ # (gitignored)
|
│ └── logs/ # (gitignored, Altlast der früheren nohup-Variante)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── setup_models.sh # Provider/Modelle einrichten (idempotent)
|
│ ├── 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
|
├── README.md # diese Datei
|
||||||
├── BEDIENUNGSANLEITUNG.md # ausführliche Bedienungsanleitung (Deutsch)
|
├── BEDIENUNGSANLEITUNG.md # ausführliche Bedienungsanleitung (Deutsch)
|
||||||
└── CLAUDE.md # technische Architektur-Doku für Weiterentwicklung
|
└── 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.
|
leere Chat-Antworten bei größerem Kontext, ohne sichtbaren Fehler.
|
||||||
- **Neue Host-Ports brauchen eine `ufw`-Regel**, sonst kann der Container sie nicht erreichen
|
- **Neue Host-Ports brauchen eine `ufw`-Regel**, sonst kann der Container sie nicht erreichen
|
||||||
(Timeout, keine Fehlermeldung in Open Notebook selbst).
|
(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
|
- **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
|
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
|
`docker-compose.yml` eingehängt) — die Original-Vorlagen im Image ignorieren `language`, was
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,14 @@ services:
|
||||||
- SURREAL_DATABASE=open_notebook
|
- SURREAL_DATABASE=open_notebook
|
||||||
- OLLAMA_BASE_URL=http://host.docker.internal:11434
|
- OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
- 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:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,57 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Startet die lokalen TTS/STT-Wrapper-Server (services/tts_server.py,
|
# Installiert und startet die lokalen TTS/STT-Wrapper-Server (services/tts_server.py,
|
||||||
# services/stt_server.py) als Hintergrundprozesse auf GPU 2. Überspringt
|
# services/stt_server.py) als systemd-User-Dienste auf GPU 2.
|
||||||
# einen Service, falls er unter seinem Port bereits antwortet.
|
#
|
||||||
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../services" && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
mkdir -p "$DIR/logs"
|
UNIT_SRC="$REPO/services/systemd"
|
||||||
cd "$DIR"
|
UNIT_DST="$HOME/.config/systemd/user"
|
||||||
|
UNITS=(open-notebook-tts.service open-notebook-stt.service)
|
||||||
|
|
||||||
start_if_needed() {
|
mkdir -p "$UNIT_DST"
|
||||||
local name="$1" port="$2" cmd="$3"
|
for unit in "${UNITS[@]}"; do
|
||||||
if curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1; then
|
install -m 644 "$UNIT_SRC/$unit" "$UNIT_DST/$unit"
|
||||||
echo "$name läuft bereits auf Port $port"
|
done
|
||||||
return
|
systemctl --user daemon-reload
|
||||||
fi
|
|
||||||
echo "Starte $name auf Port $port..."
|
# Ohne Linger laufen User-Dienste nur waehrend einer aktiven Login-Session und
|
||||||
eval "$cmd"
|
# starten nicht beim Boot.
|
||||||
disown
|
if [ "$(loginctl show-user "$USER" --property=Linger --value 2>/dev/null)" != "yes" ]; then
|
||||||
for _ in $(seq 1 15); do
|
echo "Aktiviere linger (Dienste sollen auch ohne Login laufen)..."
|
||||||
curl -sf -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && { echo "$name ist bereit."; return; }
|
loginctl enable-linger "$USER"
|
||||||
sleep 2
|
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
|
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 \
|
rc=0
|
||||||
'CUDA_VISIBLE_DEVICES=2 TTS_PORT=8901 nohup ~/miniforge3/envs/chatterbox/bin/python tts_server.py > logs/tts_server.log 2>&1 &'
|
wait_healthy tts 8901 || rc=1
|
||||||
|
wait_healthy stt 8902 || rc=1
|
||||||
|
|
||||||
start_if_needed "stt" 8902 \
|
if [ "$rc" -eq 0 ]; then
|
||||||
'CUDA_VISIBLE_DEVICES=2 STT_PORT=8902 nohup python3 stt_server.py > logs/stt_server.log 2>&1 &'
|
echo "Fertig. Status: systemctl --user status ${UNITS[*]}"
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
|
|
|
||||||
21
services/systemd/open-notebook-stt.service
Normal file
21
services/systemd/open-notebook-stt.service
Normal file
|
|
@ -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
|
||||||
23
services/systemd/open-notebook-tts.service
Normal file
23
services/systemd/open-notebook-tts.service
Normal file
|
|
@ -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
|
||||||
|
|
@ -25,6 +25,7 @@ import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path.home() / "chatterbox-tts-cli"))
|
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)
|
_DEVICE = tts.get_device(None)
|
||||||
_model_cache: dict[str, tuple] = {}
|
_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"
|
VOICES_DIR = Path(__file__).parent / "voices"
|
||||||
CLONE_LANG = "de" # language used for every cloned reference voice
|
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
|
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:
|
if not chunks:
|
||||||
raise HTTPException(status_code=422, detail="Kein synthetisierbarer Text übrig.")
|
raise HTTPException(status_code=422, detail="Kein synthetisierbarer Text übrig.")
|
||||||
|
|
||||||
model, model_kind, sr = _get_model(lang)
|
with _GPU_LOCK:
|
||||||
|
model, model_kind, sr = _get_model(lang)
|
||||||
wavs = []
|
try:
|
||||||
for chunk in chunks:
|
wavs = []
|
||||||
wavs.append(tts.generate_chunk(model, model_kind, chunk, lang, voice_path))
|
for chunk in chunks:
|
||||||
final = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1)
|
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:
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as wav_tmp:
|
||||||
wav_path = wav_tmp.name
|
wav_path = wav_tmp.name
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue