mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 15:38:10 +02:00
Voix : plus d'accélération sur les SRT, moteur local Qwen3-TTS
SRT : la voix n'est plus jamais accélérée (atempo sans plafond, jusqu'à 2× et plus, rendait la voix incompréhensible). Un morceau plus long que son sous-titre déborde et décale les suivants ; les sous-titres suivent la voix, la vidéo s'allonge et un avertissement signale le décalage. Rythme : style par défaut « neutre », consignes Gemini sans « rythme entraînant », débit Edge des styles dynamique/promo ramené à +0 %/+3 %. Nouveau moteur « qwen » : service qwen-tts (Qwen3-TTS sur CPU, gratuit, français, ton piloté par consigne, clonage de voix depuis qwen-tts/voices). Même vérification Whisper que Gemini, généralisée ; repli qwen → gemini → edge, et une lecture sous 85 % du texte est écartée (mots sautés/faux). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
This commit is contained in:
20 files changed
+579
-98
No files matched your search
@@ -0,0 +1,159 @@
|
||||
"""Synthèse vocale locale Qwen3-TTS (CPU), appelée par le service ffmpeg-api.
|
||||
|
||||
- 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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
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"))
|
||||
THREADS = int(os.environ.get("QWEN_TTS_THREADS", "0")) or os.cpu_count() or 1
|
||||
API_KEY = os.environ.get("API_KEY", "")
|
||||
|
||||
# Voix prédéfinies du modèle CustomVoice (toutes parlent français)
|
||||
PRESETS: dict[str, tuple[str, str]] = {
|
||||
"serena": ("Serena — douce et chaleureuse", "female"),
|
||||
"vivian": ("Vivian — vive et lumineuse", "female"),
|
||||
"sohee": ("Sohee — expressive", "female"),
|
||||
"ono_anna": ("Anna — espiègle", "female"),
|
||||
"aiden": ("Aiden — solaire", "male"),
|
||||
"ryan": ("Ryan — dynamique", "male"),
|
||||
"uncle_fu": ("Fu — grave et posée", "male"),
|
||||
"dylan": ("Dylan — jeune et naturelle", "male"),
|
||||
"eric": ("Eric — légèrement voilée", "male"),
|
||||
}
|
||||
DEFAULT_PRESET = "serena"
|
||||
|
||||
torch.set_num_threads(THREADS)
|
||||
_lock = asyncio.Lock()
|
||||
_models: dict[str, object] = {}
|
||||
_clone_prompts: dict[str, object] = {}
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
if name not in _models:
|
||||
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)
|
||||
return _models[name]
|
||||
|
||||
|
||||
def clones() -> dict[str, dict]:
|
||||
"""Voix de référence déposées dans voices/ : nom.wav + nom.txt (+ nom.json)."""
|
||||
found = {}
|
||||
for wav in sorted(VOICES_DIR.glob("*.wav")):
|
||||
transcript = wav.with_suffix(".txt")
|
||||
if not transcript.exists():
|
||||
log.warning("Voix %s ignorée : transcription %s absente", wav.name, transcript.name)
|
||||
continue
|
||||
meta_file = wav.with_suffix(".json")
|
||||
meta = json.loads(meta_file.read_text()) if meta_file.exists() else {}
|
||||
found[wav.stem] = {
|
||||
"id": f"clone:{wav.stem}",
|
||||
"label": meta.get("label", wav.stem.replace("_", " ").title()),
|
||||
"gender": meta.get("gender", "female"),
|
||||
"wav": wav,
|
||||
"text": transcript.read_text().strip(),
|
||||
}
|
||||
return found
|
||||
|
||||
|
||||
def _generate(text: str, voice: str, instruct: str, language: str) -> tuple[bytes, str]:
|
||||
if voice.startswith("clone:") and CLONE_MODEL:
|
||||
name = voice.removeprefix("clone:")
|
||||
clone = clones().get(name)
|
||||
if clone is None:
|
||||
raise HTTPException(status_code=404, detail=f"Voix clonée inconnue : {name}")
|
||||
model = _load(CLONE_MODEL)
|
||||
if name not in _clone_prompts:
|
||||
_clone_prompts[name] = model.create_voice_clone_prompt(
|
||||
ref_audio=str(clone["wav"]), ref_text=clone["text"], x_vector_only_mode=False
|
||||
)
|
||||
wavs, rate = model.generate_voice_clone(
|
||||
text=text, language=language, voice_clone_prompt=_clone_prompts[name]
|
||||
)
|
||||
used = clone["id"]
|
||||
else:
|
||||
speaker = voice.lower() if voice.lower() in PRESETS else DEFAULT_PRESET
|
||||
kwargs = {"text": text, "language": language, "speaker": speaker}
|
||||
if instruct:
|
||||
kwargs["instruct"] = instruct
|
||||
wavs, rate = _load(MODEL).generate_custom_voice(**kwargs)
|
||||
used = speaker
|
||||
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, wavs[0], rate, format="WAV", subtype="PCM_16")
|
||||
return buffer.getvalue(), used
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
# Chargé au démarrage : la première voix n'attend pas le chargement
|
||||
await asyncio.to_thread(_load, MODEL)
|
||||
log.info("Service prêt, %d voix clonée(s)", len(clones()))
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="SocialFlow Qwen TTS", lifespan=lifespan)
|
||||
|
||||
|
||||
def require_key(x_api_key: str | None = Header(None)) -> None:
|
||||
if API_KEY and x_api_key != API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||
|
||||
|
||||
class SynthesisRequest(BaseModel):
|
||||
text: str
|
||||
voice: str | None = None
|
||||
instruct: str | None = None
|
||||
language: str = "French"
|
||||
|
||||
|
||||
@app.get("/health", dependencies=[Depends(require_key)])
|
||||
async def health():
|
||||
return {"status": "ok", "model": MODEL, "busy": _lock.locked()}
|
||||
|
||||
|
||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||
async def voices():
|
||||
presets = [{"id": k, "label": v[0], "gender": v[1]} for k, v in PRESETS.items()]
|
||||
cloned = [{k: c[k] for k in ("id", "label", "gender")} for c in clones().values()] if CLONE_MODEL else []
|
||||
return {"voices": [*cloned, *presets]}
|
||||
|
||||
|
||||
@app.post("/synthesize", dependencies=[Depends(require_key)])
|
||||
async def synthesize(request: SynthesisRequest):
|
||||
text = request.text.strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Texte vide")
|
||||
async with _lock:
|
||||
started = time.monotonic()
|
||||
audio, used = await asyncio.to_thread(
|
||||
_generate, text, request.voice or DEFAULT_PRESET, (request.instruct or "").strip(), request.language
|
||||
)
|
||||
log.info("Voix %s : %d car. en %.1f s", used, len(text), time.monotonic() - started)
|
||||
return Response(audio, media_type="audio/wav", headers={"X-Voice": used})
|
||||
Reference in new issue
Block a user