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
This commit is contained in:
Claude committed 2026-09-28 09:21:18 +00:00
1 parent 44899055b2
commit d4c544e467
16 files changed
+338 -152

No files matched your search

+59 -25
View File
@@ -1,42 +1,76 @@
"""Traitement de la voix : filtrage, compression et niveau sonore constant."""
import json
from pathlib import Path
from . import proc
# Coupe les basses inutiles, lisse la dynamique et amène la voix à -16 LUFS :
# elle reste intelligible par-dessus la musique sans saturer.
VOICE_CHAIN = (
# Nettoyage de la voix : coupe les basses inutiles, retire le souffle de fond
# (réduction douce, sans gate qui hacherait les fins de phrases) et arrondit
# les pics. Pas de gain ajouté : c'est la normalisation qui règle le niveau.
CLEAN_CHAIN = (
"highpass=f=80,"
"acompressor=threshold=0.1:ratio=3:attack=5:release=120:makeup=2,"
"loudnorm=I=-16:TP=-1.5:LRA=7,"
"aresample=48000"
"afftdn=nr=10:nf=-50:tn=1,"
"acompressor=threshold=0.125:ratio=2.5:attack=5:release=150:makeup=1"
)
# Niveau cible de la voix : intelligible par-dessus la musique sans saturer
TARGET = "I=-16:TP=-1.5:LRA=11"
# Fondu de 5 ms aux extrémités : pas de clic au début ni à la fin
EDGE_FADE = 0.005
async def process_voice(source: Path, target: Path) -> None:
"""Produit un WAV 48 kHz mono prêt à mixer."""
await proc.run(
"""Produit un WAV 48 kHz mono prêt à mixer.
Normalisation en deux passes, en mode linéaire : un seul gain appliqué à
toute la voix. Le mode dynamique (une passe) relevait les passages calmes
et amplifiait souffles et bruits entre les phrases."""
measure = await proc.run(
[
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(source),
"-af",
VOICE_CHAIN,
"-ac",
"1",
"-ar",
"48000",
"-c:a",
"pcm_s16le",
str(target),
"ffmpeg", "-hide_banner", "-nostats", "-i", str(source),
"-af", f"{CLEAN_CHAIN},loudnorm={TARGET}:print_format=json", "-f", "null", "-",
],
timeout=120,
)
stderr_output=True,
) # fmt: skip
stats = _loudnorm_stats(measure)
if stats:
loudnorm = (
f"loudnorm={TARGET}:linear=true:"
f"measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:"
f"measured_LRA={stats['input_lra']}:measured_thresh={stats['input_thresh']}:"
f"offset={stats['target_offset']}"
)
else:
loudnorm = f"loudnorm={TARGET}"
duration = (await proc.probe(source)).duration
fades = f"afade=t=in:d={EDGE_FADE},afade=t=out:st={max(0.0, duration - EDGE_FADE):.3f}:d={EDGE_FADE}"
await proc.run(
[
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
"-af", f"{CLEAN_CHAIN},{loudnorm},aresample=48000,{fades}",
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(target),
],
timeout=120,
) # fmt: skip
def _loudnorm_stats(ffmpeg_log: str) -> dict[str, str] | None:
"""Mesures de la première passe (bloc JSON écrit par loudnorm)."""
start, end = ffmpeg_log.rfind("{"), ffmpeg_log.rfind("}")
if start < 0 or end <= start:
return None
try:
stats = json.loads(ffmpeg_log[start : end + 1])
except json.JSONDecodeError:
return None
keys = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
if not all(k in stats for k in keys):
return None
# Voix quasi muette : les mesures valent -inf, la normalisation simple suffit
if any("inf" in str(stats[k]) for k in keys):
return None
return {k: stats[k] for k in keys}
async def encode_preview(source: Path, target: Path) -> None: