Files
Socialflow/ffmpeg-service/app/tts/edge.py
T
Claude 3c6a66bf64 feat(reels): voix Gemini complète, sous-titres mot à mot et rendu de qualité
Service ffmpeg-api réécrit (ffmpeg-service/app/) :
- Appels FFmpeg asynchrones : le service ne se fige plus pendant un rendu
- Vidéo récupérée par téléchargement (GET /files/…) au lieu de base64 en JSON
- Vraies erreurs HTTP ; échec explicite si la voix demandée est impossible
- 30 voix Gemini + ton de lecture (dynamique, chaleureux, promo, calme),
  clé en en-tête, modèle configurable avec repli
- Edge TTS 7 (minutage des mots restauré), secours en voix françaises
- Voix traitée : filtre, compression, niveau constant ; musique bouclée et
  baissée automatiquement sous la voix ; mix final à -14 LUFS
- Sous-titres calés mot à mot (Whisper pour Gemini et la voix d'origine,
  à la place de ffsubsync), style Montserrat, placés hors des boutons Reels
- Vidéo : plus de retouche luminosité forcée, scaling lanczos, HDR iPhone
  converti, BT.709, AAC 48 kHz 192k ; la vidéo s'allonge si la voix dépasse
- Grand logo de fin affiché après la voix ; FFmpeg 7.0.2 épinglé, polices et
  modèle Whisper intégrés à l'image ; tests pytest et ruff

Application :
- Sélecteur de voix partagé (4 pages) : moteur, 30 voix, ton, écoute
- Reel images : minutage réel des mots, interrupteur voix respecté
- sync-info ne génère plus de voix à chaque frappe (estimation locale)
- Stabilisation désactivée par défaut, route /reels/preview inutilisée retirée
- Log « [ReelQueue] Worker démarré » pour vérifier la version déployée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-24 12:31:27 +00:00

59 lines
2.1 KiB
Python

"""Synthèse vocale Edge (gratuite, fournit le minutage des mots)."""
import logging
from pathlib import Path
import edge_tts
from .. import config
from ..align import Word
from ..voices import edge_fallbacks, resolve_edge_voice
log = logging.getLogger(__name__)
# Réglages de lecture par style (Edge n'interprète pas de consigne en texte)
STYLE_PROSODY = {
"dynamic": ("+8%", "+2Hz"),
"promo": ("+10%", "+3Hz"),
"calm": ("-8%", "-2Hz"),
"warm": ("-2%", "+0Hz"),
}
async def synthesize(
text: str, voice: str | None, style: str | None, workdir: Path
) -> tuple[Path, list[Word], str]:
"""Génère la voix ; renvoie le MP3, les mots minutés et la voix utilisée."""
rate, pitch = STYLE_PROSODY.get(style or "", ("+0%", "+0Hz"))
last_error: Exception | None = None
for candidate in edge_fallbacks(resolve_edge_voice(voice)):
target = workdir / "edge.mp3"
try:
communicate = edge_tts.Communicate(
text,
candidate.id,
rate=rate,
pitch=pitch,
# edge-tts 7 ne renvoie plus que les phrases par défaut
boundary="WordBoundary",
proxy=config.OUTBOUND_PROXY,
)
words: list[Word] = []
with target.open("wb") as out:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
out.write(chunk["data"])
elif chunk["type"] == "WordBoundary":
start = chunk["offset"] / 10_000_000
words.append(Word(chunk["text"], start, start + chunk["duration"] / 10_000_000))
if target.stat().st_size == 0:
raise RuntimeError("audio vide")
log.info("Edge TTS : voix=%s, %d mots minutés", candidate.id, len(words))
return target, words, candidate.id
except Exception as error: # noqa: BLE001 — on essaie la voix suivante
log.warning("Edge TTS %s en échec : %s", candidate.id, error)
last_error = error
raise RuntimeError(f"Aucune voix Edge disponible : {last_error}")