mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 07:28:11 +02:00
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:
24 files changed
+494
-51
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user