diff --git a/.env.example b/.env.example index 91920e8..9502c05 100644 --- a/.env.example +++ b/.env.example @@ -132,8 +132,12 @@ GEMINI_API_KEY= # demande de reconstruire ffmpeg-api. # WHISPER_MODEL=base # -# Voix locale Qwen3-TTS (service qwen-tts) : gratuite, française, sans quota, -# mais générée sur CPU (5 à 7 fois la durée de la voix sur 4 cœurs). +# Voix Qwen3-TTS : gratuite, française, sans quota. Le plus simple : l'installer +# sur un serveur avec carte graphique NVIDIA (qwen-tts/docker-compose.gpu.yml, +# ex. Unraid) puis régler l'adresse dans Paramètres → Qwen TTS. +# Sinon, service CPU sur ce serveur (lent : 5 à 7 fois la durée de la voix) : +# docker compose --profile qwen-local up -d avec QWEN_TTS_URL=http://qwen-tts:8001 +# QWEN_TTS_URL= # Modèle choisi à la construction de l'image : # - Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice (défaut) : ton et émotion pilotés ; # - Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice : ~30 % plus rapide, moins expressif. diff --git a/client/src/components/reels/qwen-settings-card.tsx b/client/src/components/reels/qwen-settings-card.tsx new file mode 100644 index 0000000..ee78e08 --- /dev/null +++ b/client/src/components/reels/qwen-settings-card.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Cpu, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient"; + +interface QwenConfig { + url: string; + hasApiKey: boolean; +} + +interface QwenTestResult { + ok: boolean; + error?: string; + model?: string; + device?: string; + voices?: number; +} + +/** + * Réglages du service Qwen3-TTS : voix locale gratuite, idéalement sur un + * serveur avec carte graphique (ex. Unraid). C'est le service FFmpeg qui + * l'appelle : le test vérifie donc la connexion depuis celui-ci. + */ +export function QwenSettingsCard() { + const { toast } = useToast(); + const [url, setUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [test, setTest] = useState(null); + + const { data: config } = useQuery({ queryKey: ["/api/settings/qwen"] }); + + useEffect(() => { + if (config) setUrl(config.url); + }, [config]); + + const refresh = () => { + queryClient.invalidateQueries({ queryKey: ["/api/settings/qwen"] }); + // Le choix du moteur « Qwen » dépend de la disponibilité du service + queryClient.invalidateQueries({ queryKey: ["/api/reels/voices"] }); + }; + + const save = useMutation({ + mutationFn: () => apiRequest("POST", "/api/settings/qwen", { url, apiKey }), + onSuccess: () => { + refresh(); + setApiKey(""); + toast({ title: "Configuration sauvegardée", description: "Service Qwen TTS enregistré" }); + }, + onError: (error) => + toast({ + title: "Erreur", + description: getErrorMessage(error, "Impossible de sauvegarder la configuration Qwen"), + variant: "destructive", + }), + }); + + const remove = useMutation({ + mutationFn: () => apiRequest("DELETE", "/api/settings/qwen"), + onSuccess: () => { + refresh(); + setUrl(""); + setApiKey(""); + setTest(null); + toast({ title: "Configuration supprimée", description: "Service Qwen TTS retiré" }); + }, + }); + + const runTest = useMutation({ + mutationFn: async () => { + const response = await apiRequest("POST", "/api/settings/qwen/test", { url, apiKey }); + return (await response.json()) as QwenTestResult; + }, + onSuccess: setTest, + onError: (error) => setTest({ ok: false, error: getErrorMessage(error, "Test impossible") }), + }); + + return ( + + + + + Qwen TTS (voix locale) + + + Voix française gratuite et expressive, générée par votre propre serveur (idéalement avec carte + graphique NVIDIA, ex. Unraid). Voir qwen-tts/docker-compose.gpu.yml. + + + +
+ + setUrl(e.target.value)} + placeholder="http://192.168.1.20:8001" + data-testid="input-qwen-url" + /> +
+
+ + setApiKey(e.target.value)} + placeholder={config?.hasApiKey ? "••••••••••••••••" : "Clé définie sur le serveur Qwen"} + data-testid="input-qwen-api-key" + /> + {config?.hasApiKey && ( +

✓ Clé enregistrée (laisser vide pour la garder)

+ )} +
+ + {test && ( +

+ {test.ok + ? `✓ Connecté : ${test.device ?? "?"} — ${test.model ?? ""} (${test.voices ?? 0} voix)` + : `✗ ${test.error}`} +

+ )} + +
+ + + {config?.url && ( + + )} +
+
+
+ ); +} diff --git a/client/src/components/reels/voice-picker.tsx b/client/src/components/reels/voice-picker.tsx index d53c86c..ea33649 100644 --- a/client/src/components/reels/voice-picker.tsx +++ b/client/src/components/reels/voice-picker.tsx @@ -148,7 +148,7 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr className={compact ? "h-7 text-xs" : undefined} variant={value.engine === "qwen" ? "default" : "outline"} disabled={!qwenAvailable && value.engine !== "qwen"} - title={qwenAvailable ? "Voix locale, gratuite, plus lente à générer" : "Service Qwen TTS non démarré"} + title={qwenAvailable ? "Voix locale, gratuite, plus lente à générer" : "Service Qwen TTS non configuré ou injoignable (Paramètres → Qwen TTS)"} onClick={() => setEngine("qwen")} > Qwen (locale) diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx index 2adba75..f46ae4d 100644 --- a/client/src/pages/settings.tsx +++ b/client/src/pages/settings.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { ModelCombobox } from "@/components/model-combobox"; +import { QwenSettingsCard } from "@/components/reels/qwen-settings-card"; import { SiTiktok } from "react-icons/si"; export default function Settings() { @@ -695,6 +696,9 @@ export default function Settings() { )} + {/* Qwen3-TTS (serveur local ou GPU distant) */} + {isAdmin && } + {isAdmin && ( diff --git a/docker-compose.yml b/docker-compose.yml index bb36012..1ea68ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,17 +86,21 @@ services: environment: API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key} GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts} - # Moteur de voix local (service qwen-tts ci-dessous) - QWEN_TTS_URL: http://qwen-tts:8001 + # Service Qwen TTS par défaut. Normalement réglé dans l'application + # (Paramètres → Qwen TTS, ex. serveur GPU Unraid) ; pour le service local + # ci-dessous : QWEN_TTS_URL=http://qwen-tts:8001 + QWEN_TTS_URL: ${QWEN_TTS_URL:-} QWEN_TTS_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key} networks: - internal expose: - "8000" - # Voix locale Qwen3-TTS (gratuite, française, sur CPU). Plus lente que - # Gemini : compter 5 à 7 fois la durée de la voix sur 4 cœurs. + # Voix Qwen3-TTS sur ce serveur, en CPU (optionnel, lent : 5 à 7 fois la + # durée de la voix sur 4 cœurs). Sur un serveur GPU, préférez + # qwen-tts/docker-compose.gpu.yml. Démarrage : docker compose --profile qwen-local up -d qwen-tts: + profiles: ["qwen-local"] build: context: ./qwen-tts dockerfile: Dockerfile diff --git a/ffmpeg-service/app/api.py b/ffmpeg-service/app/api.py index 0dfd16f..eb9ddb1 100644 --- a/ffmpeg-service/app/api.py +++ b/ffmpeg-service/app/api.py @@ -56,6 +56,9 @@ class TtsRequest(BaseModel): tts_engine: str | None = "gemini" tts_style: str | None = None gemini_api_key: str | None = None + # Service Qwen TTS réglé dans les paramètres (sinon : variables d'environnement) + qwen_tts_url: str | None = None + qwen_tts_api_key: str | None = None class SrtCue(BaseModel): @@ -80,6 +83,8 @@ class ReelRequest(BaseModel): tts_engine: str | None = "gemini" tts_style: str | None = None gemini_api_key: str | None = None + qwen_tts_url: str | None = None + qwen_tts_api_key: str | None = None draw_text: bool = True stabilize: bool = False enable_ending_effect: bool = True @@ -99,8 +104,14 @@ async def health(): @app.get("/voices", dependencies=[Depends(require_key)]) -async def list_voices(): - return voices.catalog(await tts.qwen.list_voices()) +async def list_voices(x_qwen_url: str | None = Header(None), x_qwen_key: str | None = Header(None)): + """Voix par moteur ; l'adresse du service Qwen réglée dans l'application + arrive en en-têtes (une clé n'a rien à faire dans une URL).""" + qwen_status = await tts.qwen.status(tts.qwen.Target.resolve(x_qwen_url, x_qwen_key)) + return { + **voices.catalog(qwen_status["voices"] if qwen_status else None), + "qwen_status": {k: v for k, v in (qwen_status or {}).items() if k != "voices"} or None, + } @app.post("/preview-tts", dependencies=[Depends(require_key)]) @@ -187,6 +198,7 @@ async def _synthesize(text: str, display_source: str | None, request, workdir: P style=request.tts_style, gemini_api_key=request.gemini_api_key, workdir=workdir, + qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key), ) except Exception as error: log.exception("Voix impossible à générer") @@ -212,6 +224,7 @@ async def _synthesize_srt(cues: list[srt_voice.Cue], request, workdir: Path) -> style=request.tts_style, gemini_api_key=request.gemini_api_key, workdir=workdir, + qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key), ) except Exception as error: log.exception("Voix SRT impossible à générer") diff --git a/ffmpeg-service/app/config.py b/ffmpeg-service/app/config.py index e7306c1..aead2a7 100644 --- a/ffmpeg-service/app/config.py +++ b/ffmpeg-service/app/config.py @@ -37,7 +37,8 @@ VOICE_DELAY = 2.0 SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat") -# Moteur local Qwen3-TTS (service qwen-tts). Vide : moteur indisponible. +# Service Qwen3-TTS par défaut (service qwen-tts, local ou serveur GPU) ; l'adresse +# réglée dans les paramètres de l'application est prioritaire. Vide : aucun. QWEN_TTS_URL = os.environ.get("QWEN_TTS_URL", "").rstrip("/") # Sur CPU, la génération prend plusieurs fois la durée de la voix QWEN_TTS_TIMEOUT = float(os.environ.get("QWEN_TTS_TIMEOUT", "900")) diff --git a/ffmpeg-service/app/srt_voice.py b/ffmpeg-service/app/srt_voice.py index 8597eef..dee763c 100644 --- a/ffmpeg-service/app/srt_voice.py +++ b/ffmpeg-service/app/srt_voice.py @@ -110,13 +110,16 @@ async def synthesize_cues( style: str | None, gemini_api_key: str | None, workdir: Path, + qwen_target: tts.qwen.Target | None = None, ) -> tts.VoiceTrack: """Voix complète : mots minutés en secondes depuis le début de la vidéo.""" cues = sorted((c for c in cues if clean_text(c.text)), key=lambda c: c.start) if not cues: raise ValueError("Aucun sous-titre lisible dans le fichier SRT") - options = dict(engine=engine, voice=voice, style=style, gemini_api_key=gemini_api_key) + options = dict( + engine=engine, voice=voice, style=style, gemini_api_key=gemini_api_key, qwen_target=qwen_target + ) texts = [as_sentence(c.text) for c in cues] # 1. Lecture d'une seule traite, découpée dans les silences diff --git a/ffmpeg-service/app/tts/__init__.py b/ffmpeg-service/app/tts/__init__.py index c2e6ecb..e36222f 100644 --- a/ffmpeg-service/app/tts/__init__.py +++ b/ffmpeg-service/app/tts/__init__.py @@ -32,6 +32,7 @@ async def synthesize( style: str | None, gemini_api_key: str | None, workdir: Path, + qwen_target: qwen.Target | None = None, ) -> VoiceTrack: warnings: list[str] = [] processed: Path | None = None @@ -42,7 +43,7 @@ async def synthesize( if engine == "qwen": result = await _checked( "Qwen", - lambda attempt: qwen.synthesize(text, voice, style, workdir, attempt), + lambda attempt: qwen.synthesize(text, voice, style, qwen_target, workdir, attempt), config.QWEN_TTS_ATTEMPTS, text, workdir, diff --git a/ffmpeg-service/app/tts/qwen.py b/ffmpeg-service/app/tts/qwen.py index 7c4dbcf..01bb6c2 100644 --- a/ffmpeg-service/app/tts/qwen.py +++ b/ffmpeg-service/app/tts/qwen.py @@ -1,6 +1,7 @@ -"""Synthèse vocale locale Qwen3-TTS (service qwen-tts, sans clé ni quota).""" +"""Synthèse vocale Qwen3-TTS (service qwen-tts, local ou sur un serveur GPU).""" import logging +from dataclasses import dataclass from pathlib import Path import httpx @@ -15,24 +16,39 @@ class QwenError(RuntimeError): pass -def _headers() -> dict[str, str]: - return {"x-api-key": config.QWEN_TTS_API_KEY} if config.QWEN_TTS_API_KEY else {} +@dataclass(frozen=True) +class Target: + """Service Qwen à appeler : réglé dans les paramètres de l'application, + sinon par les variables d'environnement.""" + + url: str + api_key: str = "" + + @classmethod + def resolve(cls, url: str | None = None, api_key: str | None = None) -> "Target | None": + if url and url.strip(): + return cls(url.strip().rstrip("/"), (api_key or "").strip()) + if config.QWEN_TTS_URL: + return cls(config.QWEN_TTS_URL, config.QWEN_TTS_API_KEY) + return None + + @property + def headers(self) -> dict[str, str]: + return {"x-api-key": self.api_key} if self.api_key else {} async def synthesize( - text: str, voice: str | None, style: str | None, workdir: Path, attempt: int = 0 + text: str, voice: str | None, style: str | None, target: Target | None, workdir: Path, attempt: int = 0 ) -> tuple[Path, str]: """Génère la voix ; renvoie un WAV brut et la voix utilisée.""" - if not config.QWEN_TTS_URL: - raise QwenError("service Qwen TTS non configuré (QWEN_TTS_URL)") + if target is None: + raise QwenError("service Qwen TTS non configuré (Paramètres → Qwen TTS)") qwen_voice = resolve_qwen_voice(voice) payload = {"text": text, "voice": qwen_voice, "instruct": qwen_instruction(style), "language": "French"} - log.info("Qwen TTS : voix=%s style=%s (%d car.)", qwen_voice, style, len(text)) + log.info("Qwen TTS (%s) : voix=%s style=%s (%d car.)", target.url, qwen_voice, style, len(text)) try: async with httpx.AsyncClient(timeout=config.QWEN_TTS_TIMEOUT) as client: - response = await client.post( - f"{config.QWEN_TTS_URL}/synthesize", json=payload, headers=_headers() - ) + response = await client.post(f"{target.url}/synthesize", json=payload, headers=target.headers) except httpx.HTTPError as error: raise QwenError(f"service Qwen TTS injoignable : {error!r}") from error if response.status_code != 200: @@ -42,15 +58,18 @@ async def synthesize( return raw, response.headers.get("x-voice", qwen_voice) -async def list_voices() -> list[dict] | None: - """Voix proposées par le service (clonées comprises) ; None s'il ne répond pas.""" - if not config.QWEN_TTS_URL: +async def status(target: Target | None) -> dict | None: + """État du service (modèle, carte graphique) et voix proposées, clonées + comprises ; None s'il n'est pas configuré ou ne répond pas.""" + if target is None: return None try: - async with httpx.AsyncClient(timeout=5) as client: - response = await client.get(f"{config.QWEN_TTS_URL}/voices", headers=_headers()) - response.raise_for_status() - return response.json()["voices"] + async with httpx.AsyncClient(timeout=10) as client: + health = await client.get(f"{target.url}/health", headers=target.headers) + health.raise_for_status() + listed = await client.get(f"{target.url}/voices", headers=target.headers) + listed.raise_for_status() + return {**health.json(), "voices": listed.json()["voices"]} except Exception as error: # noqa: BLE001 — moteur simplement signalé indisponible - log.warning("Service Qwen TTS indisponible : %s", error) + log.warning("Service Qwen TTS %s indisponible : %s", target.url, error) return None diff --git a/ffmpeg-service/tests/test_voices.py b/ffmpeg-service/tests/test_voices.py index af38187..2a7d8de 100644 --- a/ffmpeg-service/tests/test_voices.py +++ b/ffmpeg-service/tests/test_voices.py @@ -36,3 +36,15 @@ def test_qwen_voice_keeps_clones_and_matches_gender(): def test_qwen_voice_falls_back_to_same_gender_elsewhere(): assert resolve_edge_voice("uncle_fu").gender == "male" assert resolve_gemini_voice("uncle_fu").id == "Charon" + + +def test_qwen_target_prefers_app_settings(monkeypatch): + from app import config + from app.tts.qwen import Target + + monkeypatch.setattr(config, "QWEN_TTS_URL", "http://qwen-tts:8001") + monkeypatch.setattr(config, "QWEN_TTS_API_KEY", "env-key") + assert Target.resolve("http://unraid:8001/ ", "k") == Target("http://unraid:8001", "k") + assert Target.resolve(None, None) == Target("http://qwen-tts:8001", "env-key") + monkeypatch.setattr(config, "QWEN_TTS_URL", "") + assert Target.resolve("", None) is None diff --git a/qwen-tts/Dockerfile b/qwen-tts/Dockerfile index 64322ee..364f735 100644 --- a/qwen-tts/Dockerfile +++ b/qwen-tts/Dockerfile @@ -6,10 +6,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -# PyTorch CPU (pas de CUDA : image bien plus légère). torch et torchaudio -# doivent avoir la même version, sinon torchaudio ne se charge pas. +# PyTorch : « cpu » (image légère) ou « cu128 » pour les GPU NVIDIA, cartes +# RTX 50xx (Blackwell) comprises. torch et torchaudio doivent avoir la même +# version, sinon torchaudio ne se charge pas. +ARG TORCH_VARIANT=cpu RUN pip install --no-cache-dir torch==2.8.0 torchaudio==2.8.0 \ - --index-url https://download.pytorch.org/whl/cpu + --index-url https://download.pytorch.org/whl/${TORCH_VARIANT} COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/qwen-tts/README.md b/qwen-tts/README.md new file mode 100644 index 0000000..6353384 --- /dev/null +++ b/qwen-tts/README.md @@ -0,0 +1,55 @@ +# Qwen TTS : voix locale sur un serveur GPU (ex. Unraid) + +Service de synthèse vocale Qwen3-TTS appelé par SocialFlow (via ffmpeg-api). +Sur une carte graphique NVIDIA, le gros modèle 1.7B (ton et émotion pilotés) +génère une voix bien plus vite qu'en temps réel ; sur CPU, il est 5 à 7 fois plus lent. + +## Installation sur Unraid + +1. **Pilote NVIDIA** : Apps → installer le plugin **Nvidia Driver**, redémarrer, + puis vérifier dans un terminal : `nvidia-smi -L` (liste les cartes et leur numéro). +2. **Récupérer ce dossier** sur le serveur, par exemple : + ```bash + cd /mnt/user/appdata + git clone https://github.com/R0m1k3/Socialflow.git + cd Socialflow/qwen-tts + ``` +3. **Choisir la carte et la clé** dans un fichier `.env` à côté de `docker-compose.gpu.yml` : + ```bash + QWEN_TTS_API_KEY=une-longue-cle-secrete # openssl rand -hex 32 + QWEN_TTS_GPU=0 # numéro ou UUID donné par nvidia-smi -L + # QWEN_TTS_PORT=8001 + ``` + Une RTX 3060 12 Go suffit largement (le modèle 1.7B occupe ~5–6 Go) : autant + la dédier à la voix et garder la plus grosse carte pour le reste. +4. **Construire et lancer** (le premier build télécharge les modèles, quelques Go) : + ```bash + docker compose -f docker-compose.gpu.yml up -d --build + docker logs -f socialflow-qwen-tts # « Modèle … chargé sur NVIDIA GeForce … » + ``` +5. **Dans SocialFlow** : Paramètres → **Qwen TTS (voix locale)** → adresse + `http://IP-DE-L-UNRAID:8001` + la clé → **Tester la connexion** → **Enregistrer**. + Le moteur « Qwen (locale) » devient disponible dans le choix des voix. + +## Sécurité + +Le service ne doit pas être exposé sur Internet. Si SocialFlow tourne sur un +autre réseau que l'Unraid, reliez les deux par **Tailscale** (ou WireGuard) +et utilisez l'adresse Tailscale de l'Unraid (`http://100.x.y.z:8001`). +La clé d'accès est obligatoire. + +## Voix clonées (voix française native) + +Déposez dans `voices/` un extrait de 5 à 15 s et sa transcription (voir +`voices/README.md`), puis `docker restart socialflow-qwen-tts`. Utilisez +uniquement des voix dont vous avez les droits. + +## Réglages + +| Variable | Défaut | Rôle | +| --- | --- | --- | +| `QWEN_TTS_MODEL` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | Modèle des voix prédéfinies (build) | +| `QWEN_TTS_CLONE_MODEL` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | Modèle de clonage, vide pour désactiver (build) | +| `QWEN_TTS_GPU` | `0` | Carte NVIDIA utilisée | +| `QWEN_TTS_DEVICE` | `auto` | `cuda:0`, `cpu`… (auto : GPU si disponible) | +| `QWEN_TTS_DTYPE` | `bfloat16` sur GPU, `float32` sur CPU | Précision du calcul | diff --git a/qwen-tts/docker-compose.gpu.yml b/qwen-tts/docker-compose.gpu.yml new file mode 100644 index 0000000..d273d13 --- /dev/null +++ b/qwen-tts/docker-compose.gpu.yml @@ -0,0 +1,32 @@ +# Qwen3-TTS sur un serveur avec carte graphique NVIDIA (ex. Unraid). +# +# Prérequis : pilote NVIDIA + NVIDIA Container Toolkit (sur Unraid : plugin +# « Nvidia Driver »). Puis, dans ce dossier : +# QWEN_TTS_API_KEY=une-cle-secrete docker compose -f docker-compose.gpu.yml up -d --build +# et dans SocialFlow : Paramètres → Qwen TTS → http://IP-DU-SERVEUR:8001 + la clé. +services: + qwen-tts: + build: + context: . + args: + TORCH_VARIANT: cu128 + # Sur GPU, le gros modèle : ton et émotion pilotés par consigne + QWEN_TTS_MODEL: ${QWEN_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice} + # Clonage des voix déposées dans ./voices (vide : désactivé) + QWEN_TTS_CLONE_MODEL: ${QWEN_TTS_CLONE_MODEL-Qwen/Qwen3-TTS-12Hz-1.7B-Base} + image: socialflow-qwen-tts:gpu + container_name: socialflow-qwen-tts + restart: unless-stopped + environment: + # Obligatoire : le service est joignable depuis le réseau + API_KEY: ${QWEN_TTS_API_KEY:?Définissez QWEN_TTS_API_KEY} + # GPU utilisé dans le conteneur (voir NVIDIA_VISIBLE_DEVICES ci-dessous) + QWEN_TTS_DEVICE: cuda:0 + # Choix de la carte : numéro (0, 1) ou UUID donné par nvidia-smi -L + NVIDIA_VISIBLE_DEVICES: ${QWEN_TTS_GPU:-0} + NVIDIA_DRIVER_CAPABILITIES: compute,utility + runtime: nvidia + ports: + - "${QWEN_TTS_PORT:-8001}:8001" + volumes: + - ./voices:/app/voices:ro diff --git a/qwen-tts/main.py b/qwen-tts/main.py index 388e152..d86c6bd 100644 --- a/qwen-tts/main.py +++ b/qwen-tts/main.py @@ -1,10 +1,14 @@ -"""Synthèse vocale locale Qwen3-TTS (CPU), appelée par le service ffmpeg-api. +"""Synthèse vocale Qwen3-TTS, appelée par le service ffmpeg-api. + +Tourne sur carte graphique NVIDIA si elle est disponible (bien plus rapide, +gros modèle 1.7B), sinon sur CPU. Peut être installé sur un autre serveur +(ex. Unraid avec GPU) : l'adresse se règle dans les paramètres de SocialFlow. - Voix prédéfinies (modèle CustomVoice) : le ton est donné par une consigne. - Voix clonées : un extrait de référence dans voices/ (modèle Base, chargé seulement si au moins une voix y est déposée). -Une seule génération à la fois : chacune occupe tous les cœurs. +Une seule génération à la fois : chacune occupe tous les cœurs (ou le GPU). """ import asyncio @@ -28,7 +32,11 @@ log = logging.getLogger("qwen-tts") MODEL = os.environ.get("QWEN_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice") CLONE_MODEL = os.environ.get("QWEN_TTS_CLONE_MODEL", "Qwen/Qwen3-TTS-12Hz-0.6B-Base") VOICES_DIR = Path(os.environ.get("QWEN_TTS_VOICES_DIR", Path(__file__).parent / "voices")) -DTYPE = getattr(torch, os.environ.get("QWEN_TTS_DTYPE", "float32")) +# « auto » : premier GPU NVIDIA s'il y en a un, sinon CPU (ou « cuda:1 », « cpu »…) +_DEVICE_SETTING = os.environ.get("QWEN_TTS_DEVICE", "auto") +DEVICE = ("cuda:0" if torch.cuda.is_available() else "cpu") if _DEVICE_SETTING == "auto" else _DEVICE_SETTING +# bfloat16 sur GPU (moitié moins de mémoire, plus rapide) ; float32 sur CPU +DTYPE = getattr(torch, os.environ.get("QWEN_TTS_DTYPE") or ("bfloat16" if DEVICE.startswith("cuda") else "float32")) THREADS = int(os.environ.get("QWEN_TTS_THREADS", "0")) or os.cpu_count() or 1 API_KEY = os.environ.get("API_KEY", "") @@ -57,11 +65,17 @@ def _load(name: str): from qwen_tts import Qwen3TTSModel started = time.monotonic() - _models[name] = Qwen3TTSModel.from_pretrained(name, device_map="cpu", dtype=DTYPE) - log.info("Modèle %s chargé en %.1f s (%d threads)", name, time.monotonic() - started, THREADS) + _models[name] = Qwen3TTSModel.from_pretrained(name, device_map=DEVICE, dtype=DTYPE) + log.info("Modèle %s chargé sur %s en %.1f s", name, _device_label(), time.monotonic() - started) return _models[name] +def _device_label() -> str: + if DEVICE.startswith("cuda"): + return f"{torch.cuda.get_device_name(torch.device(DEVICE))} ({DEVICE}, {DTYPE})" + return f"CPU ({THREADS} threads, {DTYPE})" + + def clones() -> dict[str, dict]: """Voix de référence déposées dans voices/ : nom.wav + nom.txt (+ nom.json).""" found = {} @@ -135,7 +149,7 @@ class SynthesisRequest(BaseModel): @app.get("/health", dependencies=[Depends(require_key)]) async def health(): - return {"status": "ok", "model": MODEL, "busy": _lock.locked()} + return {"status": "ok", "model": MODEL, "device": _device_label(), "busy": _lock.locked()} @app.get("/voices", dependencies=[Depends(require_key)]) diff --git a/server/migrate.ts b/server/migrate.ts index 9f8259a..693e4eb 100644 --- a/server/migrate.ts +++ b/server/migrate.ts @@ -161,6 +161,13 @@ export async function migrate() { ADD COLUMN IF NOT EXISTS "gemini_api_key" text; `); + // app_config.qwen_tts_url / qwen_tts_api_key (service Qwen3-TTS) + await client.query(` + ALTER TABLE "app_config" + ADD COLUMN IF NOT EXISTS "qwen_tts_url" text, + ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text; + `); + // media.thumbnail_url (vignette conservée après suppression de la vidéo) await client.query(` ALTER TABLE "media" diff --git a/server/routes.ts b/server/routes.ts index 44b02b4..22bae42 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -14,6 +14,7 @@ import { minioService as cloudinaryService, buildMinioUrl } from "./services/min import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; import type { User, InsertUser, ScheduledPost } from "@shared/schema"; import { analyticsRouter } from "./routes/analytics"; +import { normalizeQwenUrl } from "./services/reels/assets"; import { reelsRouter } from "./routes/reels"; import { remotionRouter } from "./routes/remotion"; import { externalRouter } from "./routes/external"; @@ -473,6 +474,64 @@ export async function registerRoutes(app: Express): Promise { } }); + // Qwen3-TTS : service local ou serveur GPU distant (ex. Unraid) + app.get("/api/settings/qwen", requireAdmin, async (req, res) => { + try { + const config = await storage.getAppConfig(); + res.json({ url: config?.qwenTtsUrl ?? "", hasApiKey: !!config?.qwenTtsApiKey }); + } catch (error) { + console.error("Error fetching Qwen config:", error); + res.status(500).json({ error: "Erreur lors de la récupération de la configuration" }); + } + }); + + app.post("/api/settings/qwen", requireAdmin, async (req, res) => { + try { + const url = normalizeQwenUrl(req.body?.url); + if (!url) { + return res.status(400).json({ error: "Adresse invalide (ex. http://192.168.1.20:8001)" }); + } + const { apiKey } = req.body; + await storage.upsertAppConfig({ + qwenTtsUrl: url, + // Clé laissée vide : on garde celle déjà enregistrée + ...(typeof apiKey === "string" && apiKey.trim() ? { qwenTtsApiKey: apiKey.trim() } : {}), + }); + res.json({ success: true }); + } catch (error) { + console.error("Error saving Qwen config:", error); + res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" }); + } + }); + + app.delete("/api/settings/qwen", requireAdmin, async (req, res) => { + try { + await storage.upsertAppConfig({ qwenTtsUrl: null, qwenTtsApiKey: null }); + res.json({ success: true }); + } catch (error) { + console.error("Error deleting Qwen config:", error); + res.status(500).json({ error: "Erreur lors de la suppression de la configuration" }); + } + }); + + // Teste la connexion depuis le service FFmpeg (c'est lui qui appelle Qwen) + app.post("/api/settings/qwen/test", requireAdmin, async (req, res) => { + try { + const config = await storage.getAppConfig(); + const url = normalizeQwenUrl(req.body?.url) ?? config?.qwenTtsUrl ?? undefined; + const apiKey = (typeof req.body?.apiKey === "string" && req.body.apiKey.trim()) || config?.qwenTtsApiKey || undefined; + const { ffmpegService } = await import("./services/ffmpeg"); + const catalog = await ffmpegService.listVoices(url ? { url, apiKey } : undefined); + if (!catalog.qwen_available || !catalog.qwen_status) { + return res.json({ ok: false, error: "Service Qwen injoignable (adresse, port, clé ou pare-feu)" }); + } + res.json({ ok: true, ...catalog.qwen_status, voices: catalog.qwen.length }); + } catch (error) { + console.error("Error testing Qwen config:", error); + res.json({ ok: false, error: error instanceof Error ? error.message : "Test impossible" }); + } + }); + // Analytics Routes app.use("/api/analytics", requireAuth, analyticsRouter); diff --git a/server/routes/reels.ts b/server/routes/reels.ts index 594804e..851625a 100644 --- a/server/routes/reels.ts +++ b/server/routes/reels.ts @@ -9,7 +9,13 @@ import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg'; import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel'; import { srtText } from '@shared/srt'; import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue'; -import { resolveGeminiApiKey, resolveLogoPath, resolveStoreName } from '../services/reels/assets'; +import { + qwenTargetFromConfig, + resolveGeminiApiKey, + resolveLogoPath, + resolveQwenTarget, + resolveStoreName, +} from '../services/reels/assets'; import { configuredRenderer } from '../services/reels/videoPipeline'; import { openRouterService, describeGenerationError } from '../services/openrouter'; @@ -277,11 +283,11 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => */ reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => { try { - const catalog = await ffmpegService.listVoices(); - res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen }); + const catalog = await ffmpegService.listVoices(qwenTargetFromConfig(await storage.getAppConfig())); + res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen, qwenStatus: catalog.qwen_status }); } catch (error) { console.error('❌ Error listing voices:', error); - res.json({ qwenAvailable: false, qwen: [] }); + res.json({ qwenAvailable: false, qwen: [], qwenStatus: null }); } }); @@ -302,6 +308,7 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => { engine: ttsEngine, style: ttsStyle, geminiApiKey: await resolveGeminiApiKey(ttsEngine), + qwen: await resolveQwenTarget(ttsEngine), }); res.json({ success: true, diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 161fa39..724dbe0 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -30,6 +30,7 @@ export interface ReelRenderOptions { ttsEngine?: TtsEngine; ttsStyle?: TtsStyle; geminiApiKey?: string; + qwen?: QwenTarget; fontSize?: number; musicVolume?: number; drawText?: boolean; @@ -71,9 +72,17 @@ export interface TimedWord { end: number; } +/** Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. */ +export interface QwenTarget { + url: string; + apiKey?: string; +} + export interface VoiceCatalog { qwen: VoiceOption[]; qwen_available: boolean; + /** Modèle et carte graphique du service Qwen, null s'il ne répond pas. */ + qwen_status: { model?: string; device?: string; busy?: boolean } | null; gemini: VoiceOption[]; edge: VoiceOption[]; } @@ -149,6 +158,8 @@ export class FFmpegService { tts_engine: options.ttsEngine, tts_style: options.ttsStyle, gemini_api_key: options.geminiApiKey, + qwen_tts_url: options.qwen?.url, + qwen_tts_api_key: options.qwen?.apiKey, font_size: options.fontSize ?? 64, music_volume: options.musicVolume ?? 0.25, draw_text: options.drawText ?? true, @@ -221,6 +232,8 @@ export class FFmpegService { tts_engine: options.ttsEngine, tts_style: options.ttsStyle, gemini_api_key: options.geminiApiKey, + qwen_tts_url: options.qwen?.url, + qwen_tts_api_key: options.qwen?.apiKey, music_volume: options.musicVolume ?? 0.25, draw_text: options.drawText ?? true, stabilize: options.stabilize ?? false, @@ -294,15 +307,18 @@ export class FFmpegService { } /** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */ - async listVoices(): Promise { - const response = await this.call('/voices', { method: 'GET', timeoutMs: 15_000 }); + async listVoices(qwen?: QwenTarget): Promise { + const headers: Record = {}; + if (qwen?.url) headers['X-Qwen-Url'] = qwen.url; + if (qwen?.apiKey) headers['X-Qwen-Key'] = qwen.apiKey; + const response = await this.call('/voices', { method: 'GET', headers, timeoutMs: 30_000 }); return await response.json() as VoiceCatalog; } /** Génère la voix seule (aperçu), avec le minutage de chaque mot. */ async previewVoice( text: string, - options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string } = {}, + options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string; qwen?: QwenTarget } = {}, ): Promise { const response = await this.call('/preview-tts', { method: 'POST', @@ -313,6 +329,8 @@ export class FFmpegService { tts_engine: options.engine, tts_style: options.style, gemini_api_key: options.geminiApiKey, + qwen_tts_url: options.qwen?.url, + qwen_tts_api_key: options.qwen?.apiKey, }), timeoutMs: TTS_TIMEOUT_MS, }); diff --git a/server/services/reels/assets.ts b/server/services/reels/assets.ts index f613aa1..6c82300 100644 --- a/server/services/reels/assets.ts +++ b/server/services/reels/assets.ts @@ -3,6 +3,7 @@ * magasin), jusqu'ici recopiée dans chaque route. */ +import type { QwenTarget } from "../ffmpeg"; import { storage } from "../../storage"; import { buildMinioUrl, resolveInternalUrl } from "../minio"; @@ -49,6 +50,31 @@ export async function resolveGeminiApiKey(ttsEngine?: string): Promise { + if (ttsEngine !== "qwen") return undefined; + return qwenTargetFromConfig(await storage.getAppConfig()); +} + +/** Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; null si invalide. */ +export function normalizeQwenUrl(value: unknown): string | null { + if (typeof value !== "string" || !value.trim()) return null; + try { + const url = new URL(value.trim()); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.toString().replace(/\/+$/, ""); + } catch { + return null; + } +} + +export function qwenTargetFromConfig( + appConfig: { qwenTtsUrl?: string | null; qwenTtsApiKey?: string | null } | undefined, +): QwenTarget | undefined { + if (!appConfig?.qwenTtsUrl) return undefined; + return { url: appConfig.qwenTtsUrl, apiKey: appConfig.qwenTtsApiKey ?? undefined }; +} + /** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */ export async function resolveStoreName( userId: string, diff --git a/server/services/reels/imagesPipeline.ts b/server/services/reels/imagesPipeline.ts index a38bc61..d58ca7a 100644 --- a/server/services/reels/imagesPipeline.ts +++ b/server/services/reels/imagesPipeline.ts @@ -11,7 +11,7 @@ import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel"; import { ffmpegService } from "../ffmpeg"; import { generateVideoThumbnail } from "../thumbnail"; import type { JobContext } from "./queue"; -import { resolveGeminiApiKey, resolveLogoPath } from "./assets"; +import { resolveGeminiApiKey, resolveLogoPath, resolveQwenTarget } from "./assets"; import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets"; import { renderReelComposition } from "./remotionRenderer"; @@ -42,6 +42,7 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise; - upsertAppConfig(data: Partial>): Promise; + upsertAppConfig(data: Partial>): Promise; } export class DatabaseStorage implements IStorage { @@ -795,7 +795,7 @@ export class DatabaseStorage implements IStorage { return { ...created, clientSecret: decrypt(created.clientSecret) }; } - async upsertAppConfig(data: Partial>): Promise { + async upsertAppConfig(data: Partial>): Promise { const existing = await this.getAppConfig(); if (existing) { const [updated] = await db.update(appConfig) diff --git a/shared/schema.ts b/shared/schema.ts index fc24006..26ad8eb 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -260,6 +260,9 @@ export const appConfig = pgTable("app_config", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), externalApiKey: text("external_api_key"), geminiApiKey: text("gemini_api_key"), + // Service Qwen3-TTS (local ou serveur GPU distant) : adresse et clé d'accès + qwenTtsUrl: text("qwen_tts_url"), + qwenTtsApiKey: text("qwen_tts_api_key"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), });