mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-21 05:58:12 +02:00
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:
16 files changed
+338
-152
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import numpy as np
|
||||
|
||||
from app.align import Word
|
||||
from app.quality import check_reading, cut_point, parse_silences, speech_bounds
|
||||
from app.quality import FRAME, Envelope, check_reading, envelope_from_samples, sentence_cut
|
||||
|
||||
|
||||
def _heard(text: str) -> list[Word]:
|
||||
@@ -23,19 +25,38 @@ def test_instruction_read_aloud_is_rejected():
|
||||
assert not check_reading("Découvrez nos nouveautés en magasin", heard).acceptable
|
||||
|
||||
|
||||
def test_silences_are_parsed_from_ffmpeg_log():
|
||||
log = (
|
||||
"[silencedetect] silence_start: 0\n[silencedetect] silence_end: 0.21 | silence_duration: 0.21\n"
|
||||
"[silencedetect] silence_start: 1.5\n"
|
||||
)
|
||||
assert parse_silences(log, 2.0) == [(0.0, 0.21), (1.5, 2.0)]
|
||||
def _envelope(levels):
|
||||
"""Enveloppe synthétique : liste de (durée en s, niveau en dB)."""
|
||||
db = []
|
||||
for seconds, level in levels:
|
||||
db += [level] * int(round(seconds / FRAME))
|
||||
return Envelope(np.array(db, dtype=float))
|
||||
|
||||
|
||||
def test_speech_bounds_drop_leading_and_trailing_silence():
|
||||
start, end = speech_bounds([(0.0, 0.3), (1.5, 2.0)], 2.0, pad=0.1)
|
||||
assert round(start, 3) == 0.2 and round(end, 3) == 1.6
|
||||
def test_soft_sentence_endings_are_kept():
|
||||
# Parole à -20 dB, fin de phrase douce à -50 dB (relatif : -30 dB, audible),
|
||||
# puis vrai silence à -90 dB
|
||||
env = _envelope([(0.2, -90), (1.0, -20), (0.3, -50), (0.6, -90)])
|
||||
start, end = env.speech_span(0.0, env.duration)
|
||||
assert round(start, 2) == 0.12 # 80 ms avant la parole
|
||||
assert round(end, 2) == 1.8 # 300 ms après la fin douce (à 1,5 s)
|
||||
|
||||
|
||||
def test_cut_point_prefers_the_longest_nearby_silence():
|
||||
assert cut_point([(1.0, 1.1), (1.2, 1.8)], after=1.0, before=1.9) == 1.5
|
||||
assert cut_point([], after=1.0, before=2.0) == 1.5
|
||||
def test_cut_falls_in_the_quietest_part_of_the_pause():
|
||||
env = _envelope([(1.0, -20), (0.15, -60), (0.2, -85), (0.15, -60), (1.0, -20)])
|
||||
cut = sentence_cut(env, previous_end=0.95, next_start=1.55)
|
||||
assert 1.15 <= cut <= 1.35
|
||||
|
||||
|
||||
def test_envelope_from_samples_measures_level():
|
||||
rate = 16000
|
||||
samples = np.concatenate([np.zeros(rate // 2), 0.5 * np.ones(rate // 2)]).astype(np.float32)
|
||||
env = envelope_from_samples(samples, rate)
|
||||
assert env.db[0] < -100 and abs(env.db[-1] - 20 * np.log10(0.5)) < 0.1
|
||||
|
||||
|
||||
def test_small_recognition_errors_are_not_missing_words():
|
||||
expected = "Découvrez nos plantes de saison à petits prix. On vous attend ce week-end."
|
||||
heard = _heard("Découvrez nos plans de saison à petit prix. On vous attend ce week -end.")
|
||||
check = check_reading(expected, heard)
|
||||
assert check.acceptable, check
|
||||
@@ -154,3 +154,18 @@ def test_audio_mix_absent_without_any_sound():
|
||||
assert build_audio_mix_command(plan, Path("a.wav")) is None
|
||||
plan.keep_original_audio = True
|
||||
assert "loudnorm=I=-14" in " ".join(build_audio_mix_command(plan, Path("a.wav")))
|
||||
|
||||
|
||||
def test_final_fade_never_starts_before_the_voice_ends():
|
||||
# Vidéo courte sans logo : 2 s + 5 s de voix, la vidéo finit 0,8 s après.
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=4.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=5.0,
|
||||
)
|
||||
assert plan.total_duration == 7.8
|
||||
assert plan.fade_start >= plan.speech_end
|
||||
assert plan.fade_duration >= 0.3
|
||||
assert f"afade=t=out:st={plan.fade_start:.3f}" in _graph(build_command(plan))
|
||||
@@ -1,6 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.align import Word, spread_words
|
||||
from app.quality import Envelope
|
||||
from app.render import RenderPlan
|
||||
from app.srt_voice import (
|
||||
Cue,
|
||||
@@ -41,15 +44,16 @@ def test_words_of_the_single_reading_are_split_between_cues():
|
||||
assert split_by_cue(words, ["Bonjour."]) is None
|
||||
|
||||
|
||||
def test_reading_is_cut_inside_silences_not_on_word_bounds():
|
||||
# Whisper situe mal la fin de « Bonjour » (0,9 s) : la vraie parole finit à 1,05 s,
|
||||
# puis silence jusqu'à 1,6 s. La coupe tombe dans ce silence, sans rogner le mot.
|
||||
def test_reading_is_cut_in_the_pause_without_clipping_sentence_endings():
|
||||
# Whisper situe mal la fin de « Bonjour » (0,9 s) : la voix retombe
|
||||
# doucement jusqu'à 1,05 s, puis vraie pause jusqu'à 1,6 s.
|
||||
db = [-90.0] * 15 + [-20.0] * 75 + [-50.0] * 15 + [-90.0] * 55 + [-20.0] * 100 + [-90.0] * 40
|
||||
env = Envelope(np.array(db))
|
||||
groups = [[Word("Bonjour.", 0.2, 0.9)], [Word("Venez", 1.7, 2.1), Word("vite.", 2.1, 2.6)]]
|
||||
silences = [(0.0, 0.15), (1.05, 1.6), (2.7, 3.0)]
|
||||
first, second = segments_from_reading(groups, silences, 3.0)
|
||||
assert first.source_start < 0.15 and 1.05 < first.source_end <= 1.2
|
||||
assert 1.45 <= second.source_start < 1.6 and second.source_end > 2.7
|
||||
assert first.source_end < second.source_start
|
||||
first, second = segments_from_reading(groups, env)
|
||||
assert first.source_end >= 1.05 + 0.25 # fin douce gardée, avec sa marge
|
||||
assert first.source_end <= second.source_start
|
||||
assert 1.5 <= second.source_start < 1.6
|
||||
|
||||
|
||||
def test_each_segment_is_placed_at_its_cue_start():
|
||||
|
||||
Reference in new issue
Block a user