Files
Socialflow/ffmpeg-service/app/tts/__init__.py
T
Claude d4c544e467 fix(voix): plus de fins de phrases coupées ni de bruits parasites
Fins de phrases coupées :
- Le fondu final ne commence plus avant la fin de la voix (il rognait la
  dernière phrase quand la vidéo finissait peu après) ; il se raccourcit au
  besoin. Même règle côté Python, aperçu et rendu Remotion
- SRT : découpe au milieu de la zone la plus calme de chaque pause, mesurée
  sur l'enveloppe sonore (tranches de 10 ms), avec un seuil relatif au
  niveau de la voix qui respecte les fins douces ; 300 ms gardées après le
  dernier son

Bruits parasites :
- Normalisation de la voix en deux passes, en mode linéaire : le mode
  dynamique relevait souffles et bruits entre les phrases (mesuré : -64 dB
  avant, -75 dB après dans les pauses)
- Réduction de bruit douce (afftdn), compresseur sans gain ajouté, fondus
  de 5 ms aux extrémités ; mix final moins « pompant » (LRA 20)
- Voix Gemini vérifiée : les bruits ajoutés avant le premier ou après le
  dernier mot sont retirés

Vérification de lecture comparée lettre à lettre : les petites erreurs de
Whisper ne déclenchent plus de régénérations inutiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 09:21:18 +00:00

142 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, reading_ok = result
used_engine = "gemini"
if reading_ok:
processed, spoken = await _trim_stray_sounds(processed, spoken, workdir)
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, bool] | 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, check.acceptable
# Au-delà de la fin du dernier mot, ce n'est plus de la parole
STRAY_AFTER_LAST_WORD = 0.6
STRAY_BEFORE_FIRST_WORD = 0.4
async def _trim_stray_sounds(path: Path, spoken: list[Word], workdir: Path) -> tuple[Path, list[Word]]:
"""Retire les bruits que Gemini ajoute parfois avant le premier ou après le
dernier mot. Appelé seulement quand la lecture a été vérifiée : le dernier
mot est bien reconnu, on ne coupe donc jamais de parole."""
if not spoken:
return path, spoken
envelope = await quality.load_envelope(path)
duration = envelope.duration
window_start = max(0.0, spoken[0].start - STRAY_BEFORE_FIRST_WORD)
window_end = min(duration, spoken[-1].end + STRAY_AFTER_LAST_WORD)
start, end = envelope.speech_span(window_start, window_end)
if start < 0.02 and end > duration - 0.02:
return path, spoken
trimmed = workdir / "voice_trimmed.wav"
fade_out = max(0.0, end - start - 0.01)
await proc.run(
[
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(path),
"-af", f"atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS,"
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01",
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(trimmed),
],
timeout=60,
) # fmt: skip
log.info("Voix recadrée sur la parole : %.2f–%.2f s (sur %.2f s)", start, end, duration)
shifted = [Word(w.text, w.start - start, w.end - start) for w in spoken if w.start < end]
return trimmed, shifted
def _score(check: quality.ReadingCheck) -> float:
return check.coverage - check.extra + (0.2 if check.ending_ok else 0.0)