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
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""Tests de la detection deterministe des incises.
|
|
|
|
`detect_incises` / `incise_speaker` / `iter_incise_pieces` sont pures et
|
|
testables sans Gemma. Deux passes : inversion verbe-pronom ("dit-il") et
|
|
nominale consciente du casting ("compatit Holden", "informa le soldat").
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from inkflow.analysis.segmenter import (
|
|
detect_incises,
|
|
incise_role,
|
|
incise_speaker,
|
|
iter_incise_pieces,
|
|
)
|
|
|
|
NAMES = {"Holden", "Kajri", "Camina Drummer"}
|
|
|
|
|
|
def _pieces(text: str, names=NAMES) -> list[tuple[bool, str]]:
|
|
return iter_incise_pieces(text, detect_incises(text, names=names))
|
|
|
|
|
|
# --- Passe inversion (verbe-pronom) -----------------------------------------
|
|
|
|
def test_inversion_au_milieu():
|
|
assert _pieces("James Holden, coupa-t-elle. Je sais qui vous êtes.") == [
|
|
(False, "James Holden,"),
|
|
(True, "coupa-t-elle."),
|
|
(False, "Je sais qui vous êtes."),
|
|
]
|
|
|
|
|
|
def test_inversion_en_fin():
|
|
assert _pieces("C'est fini, dit-elle.") == [
|
|
(False, "C'est fini,"),
|
|
(True, "dit-elle."),
|
|
]
|
|
|
|
|
|
def test_inversion_reflechi_exclamation():
|
|
assert _pieces("Viens ici, s'écria-t-il !") == [
|
|
(False, "Viens ici,"),
|
|
(True, "s'écria-t-il !"),
|
|
]
|
|
|
|
|
|
def test_inversion_fermee_par_virgule():
|
|
assert _pieces("Pars, répondit-elle, et ne reviens pas.") == [
|
|
(False, "Pars,"),
|
|
(True, "répondit-elle,"),
|
|
(False, "et ne reviens pas."),
|
|
]
|
|
|
|
|
|
def test_inversion_complements_apres_pronom():
|
|
assert _pieces("Trop tard, murmura-t-il en souriant. Partons.") == [
|
|
(False, "Trop tard,"),
|
|
(True, "murmura-t-il en souriant."),
|
|
(False, "Partons."),
|
|
]
|
|
|
|
|
|
def test_double_inversion():
|
|
assert _pieces("Stop, dit-il. Non, reprit-elle.") == [
|
|
(False, "Stop,"),
|
|
(True, "dit-il."),
|
|
(False, "Non,"),
|
|
(True, "reprit-elle."),
|
|
]
|
|
|
|
|
|
# --- Incise en fin de parole : tout le reste de la replique est narration ----
|
|
|
|
def test_incise_apres_fin_de_phrase_va_jusqu_au_bout():
|
|
# Apres "…" la parole est close : "dit-il ... provisoires." est narration.
|
|
text = ("Dans une minute, oui. Je voudrais juste… dit-il avec un geste vague, "
|
|
"comme si tout cela n'avait plus d'importance.")
|
|
assert _pieces(text) == [
|
|
(False, "Dans une minute, oui. Je voudrais juste…"),
|
|
(True, "dit-il avec un geste vague, comme si tout cela n'avait plus "
|
|
"d'importance."),
|
|
]
|
|
|
|
|
|
def test_incise_apres_virgule_reprend_le_dialogue():
|
|
# Apres une simple virgule, le dialogue reprend (contraste avec ci-dessus).
|
|
assert _pieces("Pars, répondit-elle, et ne reviens pas.") == [
|
|
(False, "Pars,"),
|
|
(True, "répondit-elle,"),
|
|
(False, "et ne reviens pas."),
|
|
]
|
|
|
|
|
|
def test_incise_nominale_apres_point_interrogation_va_au_bout():
|
|
text = "Vraiment ? demanda-t-il en se levant. Il s'éloigna."
|
|
assert _pieces(text) == [
|
|
(False, "Vraiment ?"),
|
|
(True, "demanda-t-il en se levant. Il s'éloigna."),
|
|
]
|
|
|
|
|
|
# --- Passe nominale (verbe + sujet connu) -----------------------------------
|
|
|
|
def test_nominale_nom_propre():
|
|
assert _pieces("Toutes mes condoléances, compatit Holden.") == [
|
|
(False, "Toutes mes condoléances,"),
|
|
(True, "compatit Holden."),
|
|
]
|
|
|
|
|
|
def test_nominale_alias_apres_ponctuation_forte():
|
|
# "?" comme delimiteur a gauche + sujet = alias d'un personnage connu.
|
|
assert _pieces("Flippant, cet enfoiré, hein ? lança Drummer.") == [
|
|
(False, "Flippant, cet enfoiré, hein ?"),
|
|
(True, "lança Drummer."),
|
|
]
|
|
|
|
|
|
def test_nominale_clitic_et_nom_de_role():
|
|
assert _pieces("Vous venez, monsieur ? lui demanda un garde.") == [
|
|
(False, "Vous venez, monsieur ?"),
|
|
(True, "lui demanda un garde."),
|
|
]
|
|
|
|
|
|
# --- incise_speaker : seeding du locuteur explicite -------------------------
|
|
|
|
def test_seed_speaker_nom_propre():
|
|
text = "Toutes mes condoléances, compatit Holden."
|
|
inc = detect_incises(text, names=NAMES)[0]
|
|
assert incise_speaker(text, inc, NAMES) == "Holden"
|
|
|
|
|
|
def test_seed_speaker_alias_vers_canonique():
|
|
text = "Hein ? lança Drummer."
|
|
inc = detect_incises(text, names=NAMES)[0]
|
|
assert incise_speaker(text, inc, NAMES) == "Camina Drummer"
|
|
|
|
|
|
def test_seed_speaker_role_non_nomme_est_none():
|
|
# Un nom de role ("un garde") n'est pas un personnage du casting -> pas de seed.
|
|
text = "Vous venez ? lui demanda un garde."
|
|
inc = detect_incises(text, names=NAMES)[0]
|
|
assert incise_speaker(text, inc, NAMES) is None
|
|
|
|
|
|
def test_seed_speaker_inversion_est_none():
|
|
text = "C'est fini, dit-elle."
|
|
inc = detect_incises(text, names=NAMES)[0]
|
|
assert incise_speaker(text, inc, NAMES) is None
|
|
|
|
|
|
def test_seed_nom_propre_absent_du_casting():
|
|
# Le nom est ecrit dans l'incise -> seede meme si l'extraction l'a rate.
|
|
text = "Bonjour, lança Drummer."
|
|
inc = detect_incises(text, names=set())[0]
|
|
assert incise_speaker(text, inc, set()) == "Drummer"
|
|
assert _pieces(text, names=set()) == [
|
|
(False, "Bonjour,"),
|
|
(True, "lança Drummer."),
|
|
]
|
|
|
|
|
|
# --- Faux positifs a NE PAS detecter ----------------------------------------
|
|
|
|
def test_vocatif_adresse_pas_incise():
|
|
# Le personnage est interpelle, pas une incise (aucun verbe de parole).
|
|
text = "Vous n'avez pas l'air en mesure de rendre service, capitaine Holden."
|
|
assert detect_incises(text, names=NAMES) == []
|
|
|
|
|
|
def test_imperatif_sans_incise():
|
|
assert detect_incises("Donne-le-moi.", names=NAMES) == []
|
|
|
|
|
|
def test_pronom_tu_exclu():
|
|
assert detect_incises("Crois-tu ?", names=NAMES) == []
|
|
|
|
|
|
def test_replique_simple_sans_incise():
|
|
assert detect_incises("Bonjour à tous.", names=NAMES) == []
|
|
|
|
|
|
def test_sans_noms_inversion_seule():
|
|
# Sans casting fourni, la passe inversion fonctionne toujours.
|
|
assert _pieces("C'est fini, dit-elle.", names=set()) == [
|
|
(False, "C'est fini,"),
|
|
(True, "dit-elle."),
|
|
]
|
|
|
|
|
|
# --- Invariants -------------------------------------------------------------
|
|
|
|
def test_texte_preserve_modulo_espaces():
|
|
text = "James Holden, coupa-t-elle. Je sais qui vous êtes."
|
|
joined = "".join(p for _, p in _pieces(text))
|
|
assert joined.replace(" ", "") == text.replace(" ", "")
|
|
|
|
|
|
def test_bornes_non_chevauchantes_et_triees():
|
|
text = "Stop, dit-il. Non, reprit-elle."
|
|
incs = detect_incises(text, names=NAMES)
|
|
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"]
|