From b8de22def2ff385b726177e06bd2fce3ac6af578 Mon Sep 17 00:00:00 2001 From: BadStorm Developer Date: Sat, 12 Sep 2026 23:38:46 +0200 Subject: [PATCH] feat(talkbot): add Chatterbox TTS backend, generalize TTS env vars --- containers/chatterbox/build-container.sh | 22 ++++ .../chatterbox/chatterbox.Containerfile | 42 +++++++ containers/chatterbox/chatterbox.caddy | 13 ++ containers/chatterbox/chatterbox.container | 38 ++++++ containers/chatterbox/entrypoint.sh | 20 +++ containers/chatterbox/server.py | 115 ++++++++++++++++++ containers/talkbot/README.md | 26 ++-- containers/talkbot/entrypoint.sh | 2 +- containers/talkbot/server.py | 24 ++-- containers/talkbot/talkbot.container | 8 +- 10 files changed, 284 insertions(+), 26 deletions(-) create mode 100755 containers/chatterbox/build-container.sh create mode 100644 containers/chatterbox/chatterbox.Containerfile create mode 100644 containers/chatterbox/chatterbox.caddy create mode 100644 containers/chatterbox/chatterbox.container create mode 100755 containers/chatterbox/entrypoint.sh create mode 100644 containers/chatterbox/server.py diff --git a/containers/chatterbox/build-container.sh b/containers/chatterbox/build-container.sh new file mode 100755 index 0000000..ff1f0a0 --- /dev/null +++ b/containers/chatterbox/build-container.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +### Build the chatterbox image. USAGE: ./build-container.sh [cpu|rocm] (default: cpu) +set -euo pipefail + +DEVICE="${1:-cpu}" + +case "$DEVICE" in + cpu|rocm) ;; + *) + echo "Usage: $0 [cpu|rocm]" >&2 + exit 1 + ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +podman build \ + --build-arg DEVICE="$DEVICE" \ + -t "localhost/chatterbox:$DEVICE" \ + -t localhost/chatterbox:latest \ + -f "$SCRIPT_DIR/chatterbox.Containerfile" \ + "$SCRIPT_DIR" diff --git a/containers/chatterbox/chatterbox.Containerfile b/containers/chatterbox/chatterbox.Containerfile new file mode 100644 index 0000000..9bb88b3 --- /dev/null +++ b/containers/chatterbox/chatterbox.Containerfile @@ -0,0 +1,42 @@ +### Chatterbox Multilingual TTS Container (https://huggingface.co/ResembleAI/chatterbox) — build with ./build-container.sh [cpu|rocm] +ARG DEVICE=cpu +ARG BASE_IMAGE_CPU=python:3.12-slim +ARG BASE_IMAGE_ROCM=docker.io/rocm/pytorch-nightly + +FROM ${BASE_IMAGE_CPU} AS base-cpu +FROM ${BASE_IMAGE_ROCM} AS base-rocm + +FROM base-${DEVICE} +ARG DEVICE + +USER root +EXPOSE 8000 + +# sox/ffmpeg: needed at runtime for reference-audio format handling +RUN apt-get update \ + && apt-get install -y --no-install-recommends sox ffmpeg curl \ + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# CPU-only PyTorch (ROCm base image already ships its own build) +RUN if [ "$DEVICE" = "cpu" ]; then \ + pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cpu; \ + fi + +# Install chatterbox-tts and a minimal API server +RUN pip install --no-cache-dir chatterbox-tts fastapi uvicorn python-multipart soundfile + +# Models download at runtime here — mount as a volume to persist them +RUN mkdir -p /app/models +ENV HF_HOME=/app/models + +# Copy entrypoint / server script +COPY entrypoint.sh /app/entrypoint.sh +COPY server.py /app/server.py +RUN chmod +x /app/entrypoint.sh + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD [] diff --git a/containers/chatterbox/chatterbox.caddy b/containers/chatterbox/chatterbox.caddy new file mode 100644 index 0000000..1d57d6c --- /dev/null +++ b/containers/chatterbox/chatterbox.caddy @@ -0,0 +1,13 @@ +chatterbox.example.com { + import gateway_error + + request_body { + max_size 100MB + } + + reverse_proxy chatterbox:8000 + + log { + output file /var/log/caddy/chatterbox_access.log + } +} diff --git a/containers/chatterbox/chatterbox.container b/containers/chatterbox/chatterbox.container new file mode 100644 index 0000000..7b48ca9 --- /dev/null +++ b/containers/chatterbox/chatterbox.container @@ -0,0 +1,38 @@ +[Unit] +Description=Chatterbox Multilingual TTS Server +After=network-online.target +Wants=network-online.target + +[Container] +Image=localhost/chatterbox:latest +ContainerName=chatterbox +Network=internal.network + +# CPU-only container +#Memory=8g +#CPUs=4 + +# ROCm +AddDevice=/dev/kfd +AddDevice=/dev/dri/renderD128 +PodmanArgs=--group-add=keep-groups --ipc=host --pids-limit=-1 --security-opt label=disable +SecurityLabelType=container_runtime_t + +# HTTP API +PublishPort=8002:8000 + +# Persist downloaded model weights across restarts (adjust host path as needed) +Volume=/srv/containers/chatterbox/models:/app/models:Z + +# Optional: tune voice generation (defaults: 0.5, 0.5) +#Environment=CHATTERBOX_EXAGGERATION=0.5 +#Environment=CHATTERBOX_CFG_WEIGHT=0.5 + + +[Service] +Restart=on-failure +RestartSec=5 +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/containers/chatterbox/entrypoint.sh b/containers/chatterbox/entrypoint.sh new file mode 100755 index 0000000..17d1e4c --- /dev/null +++ b/containers/chatterbox/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Starts the FastAPI server; model weights download on first request into /app/models +set -e + +# wait for GPU: torch.cuda.is_available() caches its first (possibly premature) result +if [ -e /dev/kfd ]; then + echo "ROCm device detected, waiting for GPU to become available..." + for i in $(seq 1 15); do + if python3 -c "import sys, torch; sys.exit(0 if torch.cuda.is_available() else 1)" 2>/dev/null; then + echo "GPU is available." + break + fi + sleep 1 + done +fi + +echo "=== Chatterbox Server ===" +echo + +exec uvicorn server:app --host 0.0.0.0 --port 8000 --app-dir /app "$@" diff --git a/containers/chatterbox/server.py b/containers/chatterbox/server.py new file mode 100644 index 0000000..ec11344 --- /dev/null +++ b/containers/chatterbox/server.py @@ -0,0 +1,115 @@ +"""Minimal FastAPI server exposing Chatterbox Multilingual TTS. https://huggingface.co/ResembleAI/chatterbox""" +import io +import os +import tempfile + +import torch +import torchaudio as ta +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from chatterbox.mtl_tts import ChatterboxMultilingualTTS + +_forced_device = os.environ.get("CHATTERBOX_DEVICE", "").strip() +if _forced_device: + DEVICE = _forced_device +elif torch.cuda.is_available(): # ROCm builds expose the GPU via the CUDA API too + DEVICE = "cuda" +else: + DEVICE = "cpu" + +EXAGGERATION = float(os.environ.get("CHATTERBOX_EXAGGERATION", "0.5")) +CFG_WEIGHT = float(os.environ.get("CHATTERBOX_CFG_WEIGHT", "0.5")) + +# Full language name -> ISO code fallback, for callers that don't send language_code. +_NAME_TO_CODE = { + "arabic": "ar", "danish": "da", "german": "de", "greek": "el", "english": "en", + "spanish": "es", "finnish": "fi", "french": "fr", "hebrew": "he", "hindi": "hi", + "italian": "it", "japanese": "ja", "korean": "ko", "malay": "ms", "dutch": "nl", + "norwegian": "no", "polish": "pl", "portuguese": "pt", "russian": "ru", + "swedish": "sv", "swahili": "sw", "turkish": "tr", "chinese": "zh", +} + + +def resolve_language_id(language: str, language_code: str) -> str: + code = (language_code or "").strip().lower() + if code: + return code + return _NAME_TO_CODE.get((language or "").strip().lower(), "en") + + +app = FastAPI(title="Chatterbox server") + +model: ChatterboxMultilingualTTS | None = None + + +@app.on_event("startup") +def load_model(): + global model + print(f"Loading Chatterbox Multilingual TTS on {DEVICE}...") + model = ChatterboxMultilingualTTS.from_pretrained(device=DEVICE) + print("Chatterbox model loaded.") + + +@app.get("/health") +def health(): + return {"status": "ok", "device": DEVICE} + + +class SpeechRequest(BaseModel): + text: str + language: str = "English" + language_code: str = "" + + +@app.post("/speech") +def speech(req: SpeechRequest): + if model is None: + raise HTTPException(status_code=503, detail="Model not loaded yet") + + wav = model.generate( + text=req.text, + language_id=resolve_language_id(req.language, req.language_code), + exaggeration=EXAGGERATION, + cfg_weight=CFG_WEIGHT, + ) + + buf = io.BytesIO() + ta.save(buf, wav, model.sr, format="wav") + buf.seek(0) + return StreamingResponse(buf, media_type="audio/wav") + + +@app.post("/speech/clone") +def speech_clone( + text: str = Form(...), + language: str = Form("English"), + language_code: str = Form(""), + ref_audio: UploadFile = File(...), +): + """Clone a voice from ref_audio and synthesize text with it.""" + if model is None: + raise HTTPException(status_code=503, detail="Model not loaded yet") + + ref_bytes = ref_audio.file.read() + + suffix = os.path.splitext(ref_audio.filename or "")[1] or ".wav" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(ref_bytes) + tmp_path = tmp.name + + try: + wav = model.generate( + text=text, + language_id=resolve_language_id(language, language_code), + audio_prompt_path=tmp_path, + exaggeration=EXAGGERATION, + cfg_weight=CFG_WEIGHT, + ) + finally: + os.remove(tmp_path) + + buf = io.BytesIO() + ta.save(buf, wav, model.sr, format="wav") + buf.seek(0) + return StreamingResponse(buf, media_type="audio/wav") diff --git a/containers/talkbot/README.md b/containers/talkbot/README.md index 6d55c57..c5e86a1 100644 --- a/containers/talkbot/README.md +++ b/containers/talkbot/README.md @@ -50,19 +50,25 @@ curl -X POST http://localhost:8080/inference \ ``` e salvando il campo `text` risultante in `sara.txt`. +Il bot è agnostico rispetto al backend TTS: punta a un solo `TTS_URL` e +sceglie tra `/speech` (voce preset, nessun campione utente) e +`/speech/clone` (voce clonata) in base alla presenza del campione utente. +Qualsiasi backend che esponga entrambi gli endpoint va bene (qwen-tts, +omnivoice, chatterbox, ...); se il backend supporta solo `/speech/clone` +(es. omnivoice), assicurati che ogni utente abbia sempre un campione +vocale, altrimenti la richiesta di fallback a `/speech` fallirà. + Il container qwen-tts carica **entrambi i modelli** (CustomVoice e Base) nello stesso processo (`QWEN_TTS_LOAD=customvoice,voiceclone`, default), -così un'unica istanza espone sia `/speech` (voce preset) che -`/speech/clone` (voce clonata): il bot punta a un solo `QWEN_TTS_URL` e -sceglie l'endpoint giusto in base alla presenza del campione utente. -Questo costa più RAM/tempo di avvio rispetto a un solo modello, ma evita -di dover gestire due container qwen-tts separati. +così un'unica istanza espone sia `/speech` che `/speech/clone`. Questo +costa più RAM/tempo di avvio rispetto a un solo modello, ma evita di +dover gestire due container qwen-tts separati. ## File | File | Scopo | | ------------------------ | ------------------------------------------------------------------ | -| `server.py` | Logica del bot: webhook, pipeline whisper→llama.cpp→qwen-tts, risposta | +| `server.py` | Logica del bot: webhook, pipeline whisper→llama.cpp→TTS, risposta | | `entrypoint.sh` | Avvia uvicorn, valida le variabili d'ambiente richieste | | `talkbot.Containerfile` | Immagine del container | | `talkbot.container` | Quadlet Podman/systemd per l'esecuzione come servizio | @@ -102,7 +108,7 @@ Password per app), non la password reale dell'account. podman build -t talkbot:latest -f talkbot.Containerfile . cp talkbot.container ~/.config/containers/systemd/ # modifica i valori NC_URL / NC_BOT_SECRET / NC_ADMIN_USER / NC_ADMIN_PASSWORD -# e gli URL di whisper/llama.cpp/qwen-tts nel file .container +# e gli URL di whisper/llama.cpp/TTS nel file .container systemctl --user daemon-reload systemctl --user start talkbot journalctl --user -u talkbot -f @@ -131,11 +137,11 @@ conversazione Talk dove il bot è abilitato: vedi sezione "Stato dei test". | `LLAMACPP_MODEL` | no | (vuoto) | Nome modello, se il tuo router llama.cpp lo richiede | | `LLAMACPP_API_KEY` | no | (vuoto) | API key per llama.cpp (header `Authorization: Bearer ...`); se vuoto, nessun header è inviato | | `LLAMACPP_TIMEOUT` | no | `600` | Timeout (secondi) per la chiamata a llama.cpp | -| `QWEN_TTS_URL` | no | `http://qwen-tts:8000` | Endpoint qwen-tts (deve girare con `QWEN_TTS_LOAD` includendo sia `customvoice` che `voiceclone`, default) | -| `QWEN_TTS_TIMEOUT` | no | `1800` | Timeout (secondi) per la chiamata a qwen-tts | +| `TTS_URL` | no | `http://qwen-tts:8000` | Endpoint del backend TTS (qwen-tts, omnivoice, chatterbox, ...), deve esporre `/speech` e/o `/speech/clone` | +| `TTS_TIMEOUT` | no | `1800` | Timeout (secondi) per la chiamata al backend TTS | | `VOICE_SAMPLES_DIR` | no | `/app/voice-samples` | Cartella con i campioni `.wav` per il cloning | | `DEFAULT_TARGET_LANGUAGE` | no | `en` | Lingua di fallback se non si riesce a leggere quella dell'utente | -| `QWEN_TTS_SPEAKER` | no | `Ryan` | Voce preset usata quando non c'è un campione utente | +| `TTS_SPEAKER` | no | `Ryan` | Voce preset usata quando non c'è un campione utente (solo backend con `/speech`) | | `QUEUE_DB_PATH` | no | `/app/data/queue.db` | File SQLite della coda dei messaggi in attesa (vedi sotto) | > **`NC_URL` e hostname interno**: se punti `NC_URL` all'hostname del diff --git a/containers/talkbot/entrypoint.sh b/containers/talkbot/entrypoint.sh index d819e80..2dcea6a 100644 --- a/containers/talkbot/entrypoint.sh +++ b/containers/talkbot/entrypoint.sh @@ -6,7 +6,7 @@ echo "=== Nextcloud Talk voice-translate bot ===" echo "NC_URL: ${NC_URL:-}" echo "WHISPER_URL: ${WHISPER_URL:-http://whisper:8080}" echo "LLAMACPP_URL: ${LLAMACPP_URL:-http://llamacpp:7000}" -echo "QWEN_TTS_URL: ${QWEN_TTS_URL:-http://qwen-tts:8000}" +echo "TTS_URL: ${TTS_URL:-http://qwen-tts:8000}" echo "Default lang: ${DEFAULT_TARGET_LANGUAGE:-en}" echo diff --git a/containers/talkbot/server.py b/containers/talkbot/server.py index 13ee874..5878e28 100644 --- a/containers/talkbot/server.py +++ b/containers/talkbot/server.py @@ -39,16 +39,16 @@ LOG_WEBHOOK_PAYLOAD = ( in {"1", "true", "yes", "on"} ) LOG_TEXT_PREVIEW_CHARS = int(os.environ.get("LOG_TEXT_PREVIEW_CHARS") or "120") -QWEN_TTS_URL = os.environ.get("QWEN_TTS_URL", "http://qwen-tts:8000").rstrip("/") -QWEN_TTS_TIMEOUT = int(os.environ.get("QWEN_TTS_TIMEOUT") or "1800") +TTS_URL = os.environ.get("TTS_URL", "http://qwen-tts:8000").rstrip("/") +TTS_TIMEOUT = int(os.environ.get("TTS_TIMEOUT") or "1800") DEFAULT_TARGET_LANGUAGE = os.environ.get( "DEFAULT_TARGET_LANGUAGE", "it", ).strip() -QWEN_TTS_SPEAKER = os.environ.get( - "QWEN_TTS_SPEAKER", +TTS_SPEAKER = os.environ.get( + "TTS_SPEAKER", "Ryan", ).strip() @@ -883,6 +883,7 @@ def synthesize( data = { "text": text, "language": target_name, + "language_code": language_short_code(target_language).lower(), } if ref_text: @@ -892,7 +893,7 @@ def synthesize( with open(sample_path, "rb") as reference_audio: response = httpx.post( - f"{QWEN_TTS_URL}/speech/clone", + f"{TTS_URL}/speech/clone", data=data, files={ "ref_audio": ( @@ -901,24 +902,25 @@ def synthesize( "audio/wav", ) }, - timeout=QWEN_TTS_TIMEOUT, + timeout=TTS_TIMEOUT, ) else: log.info( "No voice sample for user %s, using default speaker %s", sender_user_id, - QWEN_TTS_SPEAKER, + TTS_SPEAKER, ) response = httpx.post( - f"{QWEN_TTS_URL}/speech", + f"{TTS_URL}/speech", json={ "text": text, "language": target_name, - "speaker": QWEN_TTS_SPEAKER, + "language_code": language_short_code(target_language).lower(), + "speaker": TTS_SPEAKER, }, - timeout=QWEN_TTS_TIMEOUT, + timeout=TTS_TIMEOUT, ) response.raise_for_status() @@ -1578,7 +1580,7 @@ def health(): "whisper_timeout": WHISPER_TIMEOUT, "llamacpp_timeout": LLAMACPP_TIMEOUT, "llamacpp_thinking": LLAMACPP_ENABLE_THINKING, - "qwen_tts_timeout": QWEN_TTS_TIMEOUT, + "tts_timeout": TTS_TIMEOUT, "queue_size": queue_size(), } diff --git a/containers/talkbot/talkbot.container b/containers/talkbot/talkbot.container index 6d65d1a..a030498 100644 --- a/containers/talkbot/talkbot.container +++ b/containers/talkbot/talkbot.container @@ -43,10 +43,10 @@ Environment=LLAMACPP_MAX_TOKENS=8192 Environment=LLAMACPP_ENABLE_THINKING=false Environment=LLAMACPP_TIMEOUT=1800 -# QWEN -Environment=QWEN_TTS_URL=http://qwen-tts:8000 -Environment=QWEN_TTS_TIMEOUT=5400 -Environment=QWEN_TTS_SPEAKER=Ryan +# TTS (qwen-tts, omnivoice, chatterbox, ... any backend exposing /speech + /speech/clone) +Environment=TTS_URL=http://qwen-tts:8000 +Environment=TTS_TIMEOUT=5400 +Environment=TTS_SPEAKER=Ryan # --- Behaviour ---