Qwen TTS : serveur GPU distant (Unraid) réglé dans les paramètres

- qwen-tts : GPU NVIDIA détecté automatiquement (bfloat16), image CUDA 12.8
  (RTX 50xx comprises) via TORCH_VARIANT=cu128, docker-compose.gpu.yml
  dédié (clé obligatoire, choix de la carte) et guide d'installation Unraid.
- Paramètres → « Qwen TTS (voix locale) » : adresse + clé du service, test
  de connexion (modèle et carte graphique affichés), stockés en base
  (app_config.qwen_tts_url / qwen_tts_api_key) et transmis à ffmpeg-api à
  chaque voix ; QWEN_TTS_URL reste un défaut.
- Le service qwen-tts CPU du docker-compose principal devient optionnel
  (profil qwen-local).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
This commit is contained in:
Claude committed 2026-09-29 09:57:32 +00:00
1 parent e312733d43
commit fdace9d8d5
24 files changed
+494 -51

No files matched your search

+6 -2
View File
@@ -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.
@@ -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<QwenTestResult | null>(null);
const { data: config } = useQuery<QwenConfig>({ 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 (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Cpu className="w-5 h-5" />
Qwen TTS (voix locale)
</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="qwenTtsUrl">Adresse du service</Label>
<Input
id="qwenTtsUrl"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://192.168.1.20:8001"
data-testid="input-qwen-url"
/>
</div>
<div className="space-y-2">
<Label htmlFor="qwenTtsApiKey">Clé d'accès (API_KEY du service)</Label>
<Input
id="qwenTtsApiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={config?.hasApiKey ? "••••••••••••••••" : "Clé définie sur le serveur Qwen"}
data-testid="input-qwen-api-key"
/>
{config?.hasApiKey && (
<p className="text-xs text-green-600 dark:text-green-400">✓ Clé enregistrée (laisser vide pour la garder)</p>
)}
</div>
{test && (
<p className={`text-sm ${test.ok ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
{test.ok
? `✓ Connecté : ${test.device ?? "?"} — ${test.model ?? ""} (${test.voices ?? 0} voix)`
: `✗ ${test.error}`}
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="secondary"
className="flex-1"
onClick={() => runTest.mutate()}
disabled={runTest.isPending || (!url && !config?.url)}
data-testid="button-test-qwen"
>
{runTest.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Tester la connexion
</Button>
<Button
className="flex-1"
onClick={() => save.mutate()}
disabled={save.isPending || !url}
data-testid="button-save-qwen"
>
{save.isPending ? "Enregistrement..." : "Enregistrer Qwen TTS"}
</Button>
{config?.url && (
<Button variant="outline" onClick={() => remove.mutate()} disabled={remove.isPending}>
Retirer
</Button>
)}
</div>
</CardContent>
</Card>
);
}
+1 -1
View File
@@ -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)
+4
View File
@@ -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() {
</Card>
)}
{/* Qwen3-TTS (serveur local ou GPU distant) */}
{isAdmin && <QwenSettingsCard />}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
+8 -4
View File
@@ -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
+15 -2
View File
@@ -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")
+2 -1
View File
@@ -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"))
+4 -1
View File
@@ -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
+2 -1
View File
@@ -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,
+37 -18
View File
@@ -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
+12
View File
@@ -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
+5 -3
View File
@@ -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
+55
View File
@@ -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 |
+32
View File
@@ -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
+20 -6
View File
@@ -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)])
+7
View File
@@ -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"
+59
View File
@@ -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<Server> {
}
});
// 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);
+11 -4
View File
@@ -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,
+21 -3
View File
@@ -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<VoiceCatalog> {
const response = await this.call('/voices', { method: 'GET', timeoutMs: 15_000 });
async listVoices(qwen?: QwenTarget): Promise<VoiceCatalog> {
const headers: Record<string, string> = {};
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<VoicePreview> {
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,
});
+26
View File
@@ -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<string |
return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
/** Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). */
export async function resolveQwenTarget(ttsEngine?: string): Promise<QwenTarget | undefined> {
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,
+2 -1
View File
@@ -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<I
engine: params.ttsEngine,
style: params.ttsStyle,
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
qwen: await resolveQwenTarget(params.ttsEngine),
});
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
+4 -2
View File
@@ -17,7 +17,7 @@ import { storage } from "../../storage";
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
import { resolveInternalUrl } from "../minio";
import type { JobContext } from "./queue";
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets";
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveQwenTarget } from "./assets";
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
import { renderReelComposition } from "./remotionRenderer";
@@ -39,10 +39,11 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
throw new Error("Vidéo source introuvable ou invalide");
}
const [musicUrl, logoPath, geminiApiKey] = await Promise.all([
const [musicUrl, logoPath, geminiApiKey, qwen] = await Promise.all([
resolveMusicUrl(params.musicTrackId, params.musicUrl),
resolveLogoPath(),
resolveGeminiApiKey(params.ttsEngine),
resolveQwenTarget(params.ttsEngine),
]);
const options: ReelRenderOptions = {
@@ -54,6 +55,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
ttsEngine: params.ttsEngine,
ttsStyle: params.ttsStyle,
geminiApiKey,
qwen,
fontSize: params.fontSize,
musicVolume: params.musicVolume,
drawText: params.drawText,
+2 -2
View File
@@ -210,7 +210,7 @@ export interface IStorage {
// App Config
getAppConfig(): Promise<AppConfig | undefined>;
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig>;
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig>;
}
export class DatabaseStorage implements IStorage {
@@ -795,7 +795,7 @@ export class DatabaseStorage implements IStorage {
return { ...created, clientSecret: decrypt(created.clientSecret) };
}
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig> {
const existing = await this.getAppConfig();
if (existing) {
const [updated] = await db.update(appConfig)
+3
View File
@@ -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(),
});