mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 06:58:12 +02:00
fix(voix): intonation stable, lecture vérifiée et SRT sans mots coupés
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
This commit is contained in:
11 files changed
+454
-127
No files matched your search
@@ -0,0 +1,41 @@
|
||||
from app.align import Word
|
||||
from app.quality import check_reading, cut_point, parse_silences, speech_bounds
|
||||
|
||||
|
||||
def _heard(text: str) -> list[Word]:
|
||||
return [Word(t, i * 0.3, i * 0.3 + 0.25) for i, t in enumerate(text.split())]
|
||||
|
||||
|
||||
def test_complete_reading_is_accepted():
|
||||
check = check_reading(
|
||||
"Découvrez nos nouveautés en magasin", _heard("découvrez nos nouveautés en magasin")
|
||||
)
|
||||
assert check.acceptable and check.coverage == 1.0
|
||||
|
||||
|
||||
def test_truncated_ending_is_rejected():
|
||||
check = check_reading("Découvrez nos nouveautés en magasin", _heard("découvrez nos nouveautés"))
|
||||
assert not check.ending_ok and not check.acceptable
|
||||
|
||||
|
||||
def test_instruction_read_aloud_is_rejected():
|
||||
heard = _heard("lis ce texte sur un ton dynamique découvrez nos nouveautés en magasin")
|
||||
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 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_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
|
||||
@@ -2,7 +2,16 @@ from pathlib import Path
|
||||
|
||||
from app.align import Word, spread_words
|
||||
from app.render import RenderPlan
|
||||
from app.srt_voice import Cue, atempo_chain, cue_windows, mix_command, speed_factor, spoken_span
|
||||
from app.srt_voice import (
|
||||
Cue,
|
||||
as_sentence,
|
||||
atempo_chain,
|
||||
cue_windows,
|
||||
mix_command,
|
||||
segments_from_reading,
|
||||
speed_factor,
|
||||
split_by_cue,
|
||||
)
|
||||
|
||||
|
||||
def test_window_stops_at_next_cue_and_tolerates_a_short_overflow():
|
||||
@@ -20,10 +29,27 @@ def test_atempo_is_split_into_supported_steps():
|
||||
assert atempo_chain(3.0) == "atempo=2.0000,atempo=1.5000"
|
||||
|
||||
|
||||
def test_spoken_span_trims_silences_around_words():
|
||||
words = [Word("Bonjour", 0.4, 0.9), Word("!", 0.9, 1.2)]
|
||||
assert [round(t, 3) for t in spoken_span(words, 2.0)] == [0.35, 1.32]
|
||||
assert spoken_span([], 2.0) == (0.0, 2.0)
|
||||
def test_each_cue_becomes_a_sentence_for_the_reading():
|
||||
assert as_sentence("Venez vite") == "Venez vite."
|
||||
assert as_sentence("Promo !") == "Promo !"
|
||||
|
||||
|
||||
def test_words_of_the_single_reading_are_split_between_cues():
|
||||
words = [Word(t, i, i + 0.5) for i, t in enumerate(["Bonjour.", "Venez", "vite."])]
|
||||
groups = split_by_cue(words, ["Bonjour.", "Venez vite."])
|
||||
assert [[w.text for w in g] for g in groups] == [["Bonjour."], ["Venez", "vite."]]
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
def test_each_segment_is_placed_at_its_cue_start():
|
||||
|
||||
Reference in new issue
Block a user