Files
Socialflow/qwen-tts/main.py
T
Claude 44daa88a49 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
2026-09-29 09:39:47 +00:00

160 lines
5.9 KiB
Python

"""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})