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:
Claude committed 2026-09-28 09:04:08 +00:00
1 parent f3113a0f5a
commit 44899055b2
11 files changed
+454 -127

No files matched your search

+41
View File
@@ -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
+31 -5
View File
@@ -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():