mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-20 13:38:10 +02:00
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
This commit is contained in:
47 files changed
+2206
-2285
No files matched your search
@@ -0,0 +1,63 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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}")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Synthèse vocale Gemini (API generateContent, modalité audio)."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import config, proc
|
||||
from ..voices import resolve_gemini_voice, style_instruction
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
API_ROOT = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
FALLBACK_MODEL = "gemini-2.5-flash-preview-tts"
|
||||
|
||||
|
||||
class GeminiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def build_prompt(text: str, style: str | None) -> str:
|
||||
"""Texte envoyé au modèle : la consigne de style précède le texte à lire."""
|
||||
instruction = style_instruction(style)
|
||||
return f"{instruction} :\n{text}" if instruction else text
|
||||
|
||||
|
||||
async def _request(model: str, prompt: str, voice: str, api_key: str) -> dict:
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["AUDIO"],
|
||||
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}},
|
||||
},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
# Clé en en-tête : dans l'URL, elle finissait dans les journaux
|
||||
response = await client.post(
|
||||
f"{API_ROOT}/{model}:generateContent",
|
||||
json=payload,
|
||||
headers={"x-goog-api-key": api_key},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise GeminiError(f"Gemini {model} : HTTP {response.status_code} {response.text[:300]}")
|
||||
return response.json()
|
||||
|
||||
|
||||
async def synthesize(
|
||||
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path
|
||||
) -> tuple[Path, str]:
|
||||
"""Génère la voix ; renvoie un WAV brut et le nom de la voix utilisée."""
|
||||
gemini_voice = resolve_gemini_voice(voice).id
|
||||
prompt = build_prompt(text, style)
|
||||
model = config.GEMINI_TTS_MODEL
|
||||
log.info("Gemini TTS : modèle=%s voix=%s style=%s (%d car.)", model, gemini_voice, style, len(text))
|
||||
|
||||
try:
|
||||
data = await _request(model, prompt, gemini_voice, api_key)
|
||||
except GeminiError as error:
|
||||
if model == FALLBACK_MODEL or "HTTP 404" not in str(error):
|
||||
raise
|
||||
log.warning("Modèle %s indisponible, repli sur %s", model, FALLBACK_MODEL)
|
||||
data = await _request(FALLBACK_MODEL, prompt, gemini_voice, api_key)
|
||||
|
||||
try:
|
||||
part = next(p for p in data["candidates"][0]["content"]["parts"] if "inlineData" in p)["inlineData"]
|
||||
except (KeyError, IndexError, StopIteration) as error:
|
||||
raise GeminiError(f"Réponse Gemini sans audio : {str(data)[:300]}") from error
|
||||
|
||||
audio = base64.b64decode(part["data"])
|
||||
mime = part.get("mimeType", "")
|
||||
raw = workdir / "gemini_raw.wav"
|
||||
|
||||
if "wav" in mime:
|
||||
raw.write_bytes(audio)
|
||||
else:
|
||||
# PCM brut 16 bits (« audio/L16;codec=pcm;rate=24000 »)
|
||||
rate_match = re.search(r"rate=(\d+)", mime)
|
||||
pcm = workdir / "gemini.pcm"
|
||||
pcm.write_bytes(audio)
|
||||
await proc.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"s16le",
|
||||
"-ar",
|
||||
rate_match.group(1) if rate_match else "24000",
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
str(pcm),
|
||||
str(raw),
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
pcm.unlink(missing_ok=True)
|
||||
return raw, gemini_voice
|
||||
Reference in new issue
Block a user