mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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
95 lines
3.3 KiB
Python
95 lines
3.3 KiB
Python
"""Traitement de la voix : filtrage, compression et niveau sonore constant."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from . import proc
|
|
|
|
# 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,"
|
|
"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.
|
|
|
|
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", "-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:
|
|
"""MP3 de bonne qualité pour l'écoute dans le navigateur."""
|
|
await proc.run(
|
|
[
|
|
"ffmpeg",
|
|
"-y",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-i",
|
|
str(source),
|
|
"-c:a",
|
|
"libmp3lame",
|
|
"-b:a",
|
|
"160k",
|
|
str(target),
|
|
],
|
|
timeout=120,
|
|
)
|