mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-20 13:38:10 +02:00
Intonation : - Gemini reçoit une température basse et une graine fixe (réglables) : la lecture ne change plus de ton d'une génération à l'autre - SRT : tout le texte est lu d'une seule traite puis découpé phrase par phrase, au lieu d'une génération (et d'une intonation) par sous-titre ; un seul traitement de niveau, plus de sauts de volume entre les phrases Mots coupés : - La voix est découpée dans ses vrais silences (silencedetect), plus aux bornes des mots estimées par Whisper qui rognaient les syllabes ; fondus de 10 ms aux coupes - Whisper n'est plus amorcé avec le texte attendu : il hallucinait des répétitions qui masquaient les fins tronquées Fiabilité : - Chaque voix Gemini est réécoutée : mots sautés, fin tronquée ou consigne lue à voix haute déclenchent une nouvelle génération (3 au plus), puis repli sur Edge avec avertissement - Consigne de lecture intégrale ajoutée au prompt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
105 lines
3.8 KiB
Python
105 lines
3.8 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, config, proc, quality
|
|
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] = []
|
|
processed: 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:
|
|
result = await _gemini_checked(text, voice, style, gemini_api_key, workdir, warnings)
|
|
if result:
|
|
processed, spoken, used_voice = result
|
|
used_engine = "gemini"
|
|
|
|
if processed 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:
|
|
spoken = await align.transcribe(processed)
|
|
|
|
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)
|
|
|
|
|
|
async def _gemini_checked(
|
|
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path, warnings: list[str]
|
|
) -> tuple[Path, list[Word], str] | None:
|
|
"""Voix Gemini dont la lecture a été vérifiée : Whisper réécoute chaque
|
|
génération ; mots sautés, fin tronquée ou consigne lue à voix haute
|
|
déclenchent une nouvelle génération. Renvoie None pour se replier sur Edge."""
|
|
best: tuple[quality.ReadingCheck, Path, list[Word], str] | None = None
|
|
last_error: Exception | None = None
|
|
|
|
for attempt in range(max(1, config.GEMINI_TTS_ATTEMPTS)):
|
|
try:
|
|
raw, gemini_voice = await gemini.synthesize(text, voice, style, api_key, workdir, attempt)
|
|
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis repli sur Edge
|
|
log.warning("Gemini TTS en échec (tentative %d) : %s", attempt + 1, error)
|
|
last_error = error
|
|
continue
|
|
|
|
processed = workdir / f"voice_gemini_{attempt}.wav"
|
|
await audio.process_voice(raw, processed)
|
|
spoken = await align.transcribe(processed)
|
|
check = quality.check_reading(text, spoken)
|
|
log.info("Lecture Gemini (tentative %d) : %s", attempt + 1, check.describe())
|
|
|
|
if best is None or _score(check) > _score(best[0]):
|
|
best = (check, processed, spoken, gemini_voice)
|
|
if check.acceptable:
|
|
break
|
|
|
|
if best is None:
|
|
warnings.append(f"Gemini indisponible ({last_error}) : voix Edge utilisée à la place.")
|
|
return None
|
|
|
|
check, processed, spoken, gemini_voice = best
|
|
if not check.acceptable:
|
|
if check.coverage < 0.7:
|
|
warnings.append(f"Gemini a mal lu le texte ({check.describe()}) : voix Edge utilisée à la place.")
|
|
return None
|
|
warnings.append(f"Lecture Gemini imparfaite ({check.describe()}) : écoutez le résultat.")
|
|
return processed, spoken, gemini_voice
|
|
|
|
|
|
def _score(check: quality.ReadingCheck) -> float:
|
|
return check.coverage - check.extra + (0.2 if check.ending_ok else 0.0)
|