Voicebank : vraies voix françaises (CML-TTS) + pool anonyme + garde-fou Qwen3

Remplace la voicebank générée par Kokoro (timbre anglais sur français phonémisé
-> accent que Qwen3 clonait) par 41 vraies voix FR issues de CML-TTS (livres
audio studio) : 1 narrateur dédié, 18F/14M nommées, 4F/4M anonymes réservées.

- scripts/import_voices.py : import multi-shards parquet, 1 clip/locuteur (le
  plus propre via levenshtein), genre estimé par F0 (YIN, anti-octave), filtre
  débit de parole (ref_text aligné sur l'audio).
- VoiceEntry.anonymous + assign_voices : les figurants « anonyme (...) » tirent
  dans un pool réservé, jamais mélangé avec les voix nommées ; narrateur dédié
  (fr_narrator remplace fr_f_siwis).
- dedup._anon_attrs : genre/âge déduits du nom anonyme (bon genre de voix).
- tts/qwen3.py : garde-fou anti-dérive (rejette/réessaie les sorties en boucle
  ou coupées en estimant la durée plausible du chunk).

Limite connue : Qwen3 ne sait pas synthétiser les fragments d'1-2 mots (incises,
titres) -> trous ; à traiter (repli Kokoro ou fusion des incises).

Inclut aussi du travail en cours antérieur (refacto backend LLM pluggable
mlx/lmstudio, benchmark, ajustements frontend/API).

Claude-Session: https://claude.ai/code/session_01XSVvcy1mfb4k1xDgib9vVU
This commit is contained in:
2026-06-21 21:32:31 +02:00
parent 141df5f04e
commit ba1813c583
91 changed files with 2558 additions and 442 deletions

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
from inkflow.analysis.segmenter import (
detect_incises,
incise_role,
incise_speaker,
iter_incise_pieces,
)
@@ -202,3 +203,125 @@ def test_bornes_non_chevauchantes_et_triees():
assert all(incs[i].end <= incs[i + 1].start for i in range(len(incs) - 1))
for inc in incs:
assert 0 <= inc.start < inc.end <= len(text)
# --- Passe deterministe : reparation de l'alternance des tours ---------------
from inkflow.analysis.segmenter import _repair_alternation # noqa: E402
from inkflow.models import Incise, Segment, SegmentType # noqa: E402
def _D(text: str, speaker: str, incises=None) -> Segment:
return Segment(type=SegmentType.DIALOGUE, text=text, speaker=speaker,
incises=incises or [])
def _N(text: str = "narration") -> Segment:
return Segment(type=SegmentType.NARRATION, text=text, speaker="narrateur")
def _speakers(segments, sl):
return [segments[i].speaker for i in sl]
def test_alternance_corrige_doublons_de_tour():
# Echange a deux, le modele a double des tours (D,H,H) -> doit redevenir D,H,D.
segs = [
_N(),
_D("Je suis ravie.", "Drummer"),
_D("C'est moche.", "Holden"),
_D("Je ne devrais pas la ramener.", "Holden"), # erreur
_N(),
]
_repair_alternation(segs, names={"Drummer", "Holden"})
assert _speakers(segs, [1, 2, 3]) == ["Drummer", "Holden", "Drummer"]
def test_alternance_ancre_par_incise_nominale():
# Seed nominal en tete (compatit Holden) -> fixe la parite du motif.
t0 = "Toutes mes condoléances, compatit Holden."
seed = [Incise(start=t0.index("compatit"), end=len(t0))]
segs = [
_N(),
_D(t0, "Holden", seed),
_D("Merci.", "Kajri"),
_D("Nous n'avons pas été présentés.", "Kajri"), # erreur
_D("James Holden.", "Holden"), # erreur
_D("Ah, croustillant.", "Kajri"), # erreur
_N(),
]
_repair_alternation(segs, names={"Holden", "Kajri"})
assert _speakers(segs, [1, 2, 3, 4, 5]) == [
"Holden", "Kajri", "Holden", "Kajri", "Holden"]
def test_alternance_trois_locuteurs_ancres_sabstient():
# Un 3e locuteur (meme via incise) dans le run -> pas d'alternance binaire forcee.
ta = "Ça satisfait, disait Bobbie."
tb = "Oui, convint Naomi."
tc = "Avec des jeunes, précisa Alex."
segs = [
_N(),
_D(ta, "Bobbie", [Incise(start=ta.index("disait"), end=len(ta))]),
_D(tb, "Naomi", [Incise(start=tb.index("convint"), end=len(tb))]),
_D(tc, "Alex", [Incise(start=tc.index("précisa"), end=len(tc))]),
_N(),
]
_repair_alternation(segs, names={"Bobbie", "Naomi", "Alex"})
assert _speakers(segs, [1, 2, 3]) == ["Bobbie", "Naomi", "Alex"]
def test_alternance_run_deja_correct_inchange():
segs = [_N(), _D("a", "Holden"), _D("b", "Kajri"),
_D("c", "Holden"), _D("d", "Kajri"), _N()]
before = _speakers(segs, [1, 2, 3, 4])
_repair_alternation(segs, names={"Holden", "Kajri"})
assert _speakers(segs, [1, 2, 3, 4]) == before
def test_alternance_trois_locuteurs_sabstient():
# 3 locuteurs distincts dans le run -> pas d'alternance binaire, on ne touche pas.
segs = [_N(), _D("a", "Holden"), _D("b", "Kajri"),
_D("c", "Drummer"), _N()]
_repair_alternation(segs, names={"Holden", "Kajri", "Drummer"})
assert _speakers(segs, [1, 2, 3]) == ["Holden", "Kajri", "Drummer"]
def test_alternance_narration_intercalee_rompt_le_run():
# STRICT (GAP=0) : toute narration entre deux repliques coupe le run, car
# elle peut porter une continuation du meme locuteur (cf. ch06). On ne force
# donc PAS l'alternance a travers une narration.
segs = [_N(), _D("a", "Drummer"), _N("il marqua une pause"),
_D("b", "Holden"), _D("c", "Holden"), _N()]
_repair_alternation(segs, names={"Holden", "Drummer"})
# Le run effectif est [b, c] (consecutifs) : 1 seul locuteur resolu -> abstention.
assert _speakers(segs, [1, 3, 4]) == ["Drummer", "Holden", "Holden"]
def test_incise_role_renvoie_le_nom_de_role():
# "informa le soldat" : pas un locuteur NOMME, mais un role identifiable.
text = "La réception commence, madame, informa le soldat."
inc = detect_incises(text, names=NAMES)[0]
assert incise_speaker(text, inc, NAMES) is None # pas de nom propre
assert incise_role(text, inc, NAMES) == "soldat" # role detecte
# Un nom propre n'est pas un role.
text2 = "Bonjour, lança Drummer."
inc2 = detect_incises(text2, names=set())[0]
assert incise_role(text2, inc2, set()) is None
def test_alternance_seed_contradictoire_sabstient():
# Deux seeds nominaux contradictoires avec toute alternance -> abstention.
ta = "Bonjour, dit Holden."
tb = "Salut, répondit Holden."
segs = [
_N(),
_D(ta, "Holden", [Incise(start=ta.index("dit"), end=len(ta))]),
_D("Entre les deux.", "Kajri"),
_D(tb, "Holden", [Incise(start=tb.index("répondit"), end=len(tb))]),
_N(),
]
# Motif alterne impossible (Holden en 0 et 2 exige Kajri en 1, OK en fait) :
# ici l'alternance H,K,H EST coherente avec les deux ancres -> applique.
_repair_alternation(segs, names={"Holden", "Kajri"})
assert _speakers(segs, [1, 2, 3]) == ["Holden", "Kajri", "Holden"]