Files
Socialflow/ffmpeg-service/app/tts/__init__.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

64 lines
2.1 KiB
Python

"""Synthèse vocale : moteur au choix, repli sur Edge, voix traitée et mots minutés."""
import logging
from dataclasses import dataclass, field
from pathlib import Path
from .. import align, audio, proc
from ..align import Word
from . import edge, gemini
log = logging.getLogger(__name__)
@dataclass
class VoiceTrack:
path: Path # WAV 48 kHz traité
duration: float
words: list[Word] # mots affichés, minutés depuis le début de la voix
engine: str
voice: str
warnings: list[str] = field(default_factory=list)
async def synthesize(
*,
text: str,
display_text: str,
engine: str | None,
voice: str | None,
style: str | None,
gemini_api_key: str | None,
workdir: Path,
) -> VoiceTrack:
warnings: list[str] = []
raw: Path | None = None
spoken: list[Word] = []
used_engine, used_voice = "edge", ""
if (engine or "gemini") == "gemini":
if not gemini_api_key:
warnings.append("Clé Gemini absente : voix Edge utilisée à la place.")
else:
try:
raw, used_voice = await gemini.synthesize(text, voice, style, gemini_api_key, workdir)
used_engine = "gemini"
except Exception as error: # noqa: BLE001 — repli sur Edge
log.warning("Gemini TTS en échec, repli sur Edge : %s", error)
warnings.append(f"Gemini indisponible ({error}) : voix Edge utilisée à la place.")
if raw is None:
raw, spoken, used_voice = await edge.synthesize(text, voice, style, workdir)
processed = workdir / "voice.wav"
await audio.process_voice(raw, processed)
duration = (await proc.probe(processed)).duration
if not spoken:
# Gemini ne donne pas le minutage : Whisper le retrouve dans l'audio
spoken = await align.transcribe(processed, hint=text)
words = align.align_words(display_text, spoken, duration)
log.info("Voix prête : %s/%s, %.1f s, %d mots", used_engine, used_voice, duration, len(words))
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)