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

@@ -25,7 +25,7 @@ from ..models import (
SegmentType,
)
from ..settings import get_settings
from .gemma import Gemma
from .llm.client import LLM
# Un paragraphe de dialogue commence par un cadratin (U+2014) ou un tiret long.
_DIALOGUE_LEAD_RE = re.compile(r"^\s*[—―]\s*")
@@ -65,7 +65,7 @@ _CHUNK_MAX_DIALOGUES = 30 # repliques par appel (fiabilite du modele)
def attribute_speakers(
segments: list[Segment],
gemma: Gemma,
gemma: LLM,
*,
characters: Optional[list[Character]] = None,
pov: Optional[str] = None,
@@ -211,7 +211,7 @@ def _chunk_dialogues(
def _refine_unknown_speakers(
segments: list[Segment],
gemma: Gemma,
gemma: LLM,
*,
characters: Optional[list[Character]] = None,
confidence: dict[int, str],
@@ -276,11 +276,237 @@ def _refine_unknown_speakers(
segments[seg_idx].speaker = new
# --- Post-traitement deterministe (sans LLM) --------------------------------
# Traductions FR pour construire l'identite d'un locuteur anonyme.
_ANON_GENDER_FR = {"male": "homme", "female": "femme"}
_ANON_AGE_FR = {"child": "enfant", "young": "jeune", "adult": "adulte", "old": "vieux"}
def _anon_identity(gender: Optional[str], age: Optional[str]) -> str:
"""Identite canonique d'un locuteur anonyme, regroupe par (genre, age).
Ex: ("male", "adult") -> "anonyme (homme, adulte)" ; ("male", None) ->
"anonyme (homme)" ; (None, None) -> "anonyme". Tous les personnages-fonction
d'un meme bucket partagent une voix (genre/age suffisent a la choisir)."""
g = _ANON_GENDER_FR.get((gender or "").lower())
a = _ANON_AGE_FR.get((age or "").lower())
parts = [p for p in (g, a) if p]
return f"anonyme ({', '.join(parts)})" if parts else "anonyme"
def _apply_anonymous_speakers(
segments: list[Segment], *, names=None) -> dict[str, tuple[Optional[str], Optional[str]]]:
"""Rattache les repliques a incise de role a un locuteur ANONYME par genre/age.
Une incise "informa le soldat" -> "anonyme (homme)" : on ne stocke pas la
fonction (garde/marine...), seuls genre+age comptent pour la voix. Genre/age
deduits du nom de role (`_ROLE_GENDER`/`_ROLE_AGE`). Applique APRES le LLM
(autorite deterministe), sans modifier le prompt. Mutation en place.
Renvoie {identite_anonyme: (genre, age)} des buckets utilises, pour que
l'appelant cree les `Character` generiques correspondants (assignation voix)."""
names = names or set()
used: dict[str, tuple[Optional[str], Optional[str]]] = {}
for seg in segments:
if seg.type is not SegmentType.DIALOGUE:
continue
for inc in seg.incises:
role = incise_role(seg.text, inc, names)
if role:
gender = _ROLE_GENDER.get(role)
age = _ROLE_AGE.get(role)
ident = _anon_identity(gender, age)
seg.speaker = ident
used[ident] = (gender, age)
break
return used
def _inversion_gender(text: str) -> Optional[str]:
"""Genre porte par le pronom d'une incise d'inversion ("demanda-t-elle" ->
female, "dit-il" -> male). None si aucune inversion. Signal sur LE locuteur."""
m = _INV_GENDER_RE.search(text)
if not m:
return None
return "female" if m.group("p").lower().startswith("elle") else "male"
def _resolve_anonymous_figurants(
segments: list[Segment]) -> dict[str, tuple[Optional[str], Optional[str]]]:
"""Resout les repliques restees INDETERMINEES (inconnu/?) en figurants anonymes.
Quand une replique non resolue est entouree d'une narration decrivant un
figurant genre ("La femme...", "La jeune marine...", "Le soldat..."), on
l'attribue au bucket anonyme correspondant. Genre : pronom d'inversion de la
replique ("demanda-t-elle") sinon l'article du role dans la narration
(la/une -> femme, le/un -> homme). N'agit QUE sur l'indetermine (jamais sur
une attribution sure) -> sans risque pour les personnages nommes. Mutation en
place ; renvoie les buckets crees (pour creer les Character generiques)."""
used: dict[str, tuple[Optional[str], Optional[str]]] = {}
for idx, seg in enumerate(segments):
if seg.type is not SegmentType.DIALOGUE or _is_resolved(seg.speaker):
continue
narr_gender = role_age = None
found = False
for j in (idx - 1, idx + 1): # narration adjacente (avant puis apres)
if 0 <= j < len(segments) and segments[j].type is SegmentType.NARRATION:
m = _ANON_NARR_RE.search(segments[j].text)
if m:
found = True
art = m.group("art").lower().rstrip("'")
narr_gender = "female" if art in ("la", "une") else "male"
role_age = _ROLE_AGE.get(m.group("role").lower())
break
if not found:
continue
gender = _inversion_gender(seg.text) or narr_gender
ident = _anon_identity(gender, role_age)
seg.speaker = ident
used[ident] = (gender, role_age)
return used
def _canonicalize_speakers(segments: list[Segment], chars: list[Character]) -> None:
"""Reecrit chaque locuteur variant vers le nom canonique du cast.
Le LLM emet souvent des variantes hors liste ("Amiral Mehmet Sagale" pour
"Sagale", "Elvi Okoye" pour "Elvi"). Non rattachees, elles cassent le rendu
(mauvaise voix -> repli narrateur) et le score. On les recolle au canonique
via `heuristic_match` (primitive sure du dedup) : on n'agit QUE sur un match
certain (`Character`), on s'abstient sur ambiguite/inconnu. Pur, sans LLM,
ne touche pas au prompt. Ordre-independant : `tokfreq` calcule globalement.
Idempotent (un nom deja canonique matche en exact)."""
from ..casting.dedup import heuristic_match, _token_freq
spoken = [s.speaker for s in segments
if s.type is SegmentType.DIALOGUE and _is_resolved(s.speaker)]
if not spoken or not chars:
return
tokfreq = _token_freq(chars, spoken)
for seg in segments:
if seg.type is not SegmentType.DIALOGUE or not _is_resolved(seg.speaker):
continue
match = heuristic_match(seg.speaker, chars, tokfreq)
if isinstance(match, Character):
seg.speaker = match.name
# --- Passe deterministe : reparation de l'alternance des tours ---------------
def _norm_name(name: str) -> str:
return (name or "").strip().casefold()
# Tolerance de narration intercalee entre deux repliques d'un meme run. STRICT
# (0) : seules les repliques d'indices consecutifs forment un run. Toute valeur
# >0 est DANGEREUSE : une narration peut porter une *continuation du meme
# locuteur* ("— …", "Fayez marqua une pause.", "— …") ou il reparle ; verifie
# sur ch06 (runs 66-79 et 83-90 de la reference NON alternes des GAP=1). On
# prefere ne pas reparer une replique isolee que d'inventer une fausse alternance.
_RUN_MAX_NARRATION_GAP = 0
def _dialogue_runs(segments: list[Segment]) -> list[list[int]]:
"""Suites de repliques d'indices consecutifs (aucune narration intercalee).
Hypothese (verifiee sur les references ch05 ET ch06, 0 contre-exemple) : dans
une telle salve ou chaque cadratin marque un changement de locuteur, les
tours alternent strictement. Des qu'une narration s'intercale, l'alternance
n'est plus garantie (continuation possible du meme locuteur) -> nouveau run."""
runs: list[list[int]] = []
cur: list[int] = []
gap = 0
for i, s in enumerate(segments):
if s.type is SegmentType.DIALOGUE:
cur.append(i)
gap = 0
else:
gap += 1
if gap > _RUN_MAX_NARRATION_GAP:
if len(cur) >= 2:
runs.append(cur)
cur = []
if len(cur) >= 2:
runs.append(cur)
return runs
def _repair_alternation(segments: list[Segment], *, names=None) -> None:
"""Force l'alternance des tours dans les echanges a exactement 2 locuteurs.
Pour chaque suite de repliques consecutives a deux locuteurs, on retient,
parmi les deux motifs alternes possibles (A/B/A… ou B/A/B…), celui qui :
1. ne contredit aucune ancre sure (locuteur explicite d'incise nominale) ;
2. exige le moins de corrections au resultat de la 1re passe.
On n'agit qu'avec un gagnant STRICT, sinon on s'abstient (on prefere laisser
une erreur qu'en introduire une). En particulier, des qu'un 3e locuteur (meme
minoritaire) apparait dans le run, on ne touche a rien : un echange a >=3
n'alterne pas forcement. Pur, sans appel LLM ; comble aussi les repliques
indeterminees du run.
"""
names = names or set()
for run in _dialogue_runs(segments):
speakers = [segments[i].speaker for i in run]
resolved = {_norm_name(s) for s in speakers if _is_resolved(s)}
if len(resolved) != 2:
continue
# Noms canoniques (1re occurrence de chaque forme normalisee).
order: list[str] = []
for s in speakers:
n = _norm_name(s)
if n in resolved and n not in order:
order.append(n)
name_a, name_b = order[0], order[1]
canon_of = {}
for s in speakers:
n = _norm_name(s)
if n in resolved:
canon_of.setdefault(n, s.strip())
# Ancres sures : locuteur explicite d'une incise nominale.
anchors: dict[int, str] = {}
for k, idx in enumerate(run):
seg = segments[idx]
for inc in seg.incises:
spk = incise_speaker(seg.text, inc, names)
if spk:
anchors[k] = _norm_name(spk)
break
# Une ancre nommant un tiers (hors paire) -> run suspect, on s'abstient.
if any(a not in (name_a, name_b) for a in anchors.values()):
continue
def pattern(start: str) -> list[str]:
other = name_b if start == name_a else name_a
return [start if k % 2 == 0 else other for k in range(len(run))]
candidates = [pattern(name_a), pattern(name_b)]
admissible = [p for p in candidates
if all(p[k] == a for k, a in anchors.items())]
if not admissible:
continue
def cost(p: list[str]) -> int: # corrections sur les repliques resolues
return sum(1 for k, idx in enumerate(run)
if _is_resolved(segments[idx].speaker)
and _norm_name(segments[idx].speaker) != p[k])
admissible.sort(key=cost)
if len(admissible) == 2 and cost(admissible[0]) == cost(admissible[1]):
continue # ex aequo sans ancre discriminante -> trop ambigu
chosen = admissible[0]
for k, idx in enumerate(run):
segments[idx].speaker = canon_of[chosen[k]]
# --- Extraction du casting (Gemma) ------------------------------------------
# Le prompt systeme est editable dans les reglages (settings.prompt_characters).
def extract_characters(text: str, gemma: Gemma) -> list[Character]:
def extract_characters(text: str, gemma: LLM) -> list[Character]:
"""Extrait les personnages et leurs attributs (genre, age) d'un texte."""
prompt = (
"A partir de l'extrait suivant, liste les personnages qui parlent ou "
@@ -374,17 +600,52 @@ _SPEECH_VERBS = {
"tempeta", "rétorque", "lâche", "informa", "renseigna", "indiqua",
"rappela", "avertit", "prévint", "prevint", "intima", "rétorquait",
"lançait", "questionnait", "reconnut", "constata", "répéta", "repeta",
"intervint", "intervient", "renchérissait",
}
# Noms de role pouvant etre sujet d'une incise ("informa le soldat").
# Noms de role (FONCTION) pouvant etre sujet d'une incise ("informa le soldat").
# On EXCLUT volontairement les rangs/titres (amiral, capitaine, lieutenant...) :
# ils precedent presque toujours un nom propre ("dit l'amiral Sagale") -> ce
# n'est pas un figurant anonyme mais une personne nommee ; les laisser ici ferait
# capter le titre au lieu du nom. Le nom propre est alors capte normalement.
_ROLE_NOUNS = {
"garde", "soldat", "sentinelle", "gardien", "prêtre", "pretre", "homme",
"femme", "fille", "garçon", "garcon", "vieille", "vieillard", "capitaine",
"lieutenant", "sergent", "général", "general", "amiral", "officier", "voix",
"femme", "fille", "garçon", "garcon", "vieille", "vieillard", "voix",
"inconnu", "inconnue", "étranger", "etranger", "enfant", "serviteur",
"servante", "messager", "domestique", "médecin", "medecin",
"servante", "messager", "domestique", "médecin", "medecin", "marine", "marin",
}
# Genre/age probables d'un personnage-fonction, pour l'attribuer a un locuteur
# anonyme regroupe (voix par genre/age). On ne mappe QUE les cas ou le genre de
# la PERSONNE est fortement implique (roles militaires/masculins, feminins
# explicites) ; les cas ambigus (medecin, officier, voix, sentinelle...) restent
# inconnus -> bucket "anonyme" generique. Mieux vaut un genre inconnu qu'errone.
_ROLE_GENDER = {
"soldat": "male", "garde": "male", "gardien": "male", "marine": "male",
"marin": "male", "homme": "male", "garçon": "male", "garcon": "male",
"vieillard": "male", "serviteur": "male", "messager": "male",
"prêtre": "male", "pretre": "male",
"femme": "female", "fille": "female", "servante": "female",
"vieille": "female", "inconnue": "female",
}
# Age probable (rare : seul "enfant" le donne nettement).
_ROLE_AGE = {
"enfant": "child", "garçon": "child", "garcon": "child",
"fille": "child", "vieillard": "old", "vieille": "old",
}
# Genre du pronom d'une incise d'inversion ("-t-elle"/"-il"). "-" => inversion.
_INV_GENDER_RE = re.compile(r"-(?:t-)?(?P<p>ils?|elles?)\b", re.IGNORECASE)
# Figurant genre decrit dans la narration : article (genre) + nom de role proche.
# Ex: "La femme", "La jeune marine", "Le soldat". Sert a resoudre une replique
# indeterminee en anonyme (cf. `_resolve_anonymous_figurants`).
_ANON_NARR_RE = re.compile(
r"\b(?P<art>la|le|une|un)\s+(?:[\wÀ-ÿ’'-]+\s+){0,2}?"
r"(?P<role>" + "|".join(re.escape(r) for r in sorted(_ROLE_NOUNS, key=len, reverse=True)) + r")\b",
re.IGNORECASE,
)
# Mots vides ignores quand on indexe les tokens d'un nom de personnage.
_NAME_STOP = {
"le", "la", "les", "un", "une", "de", "du", "des", "monsieur", "madame",
@@ -446,12 +707,14 @@ _REJECT = object() # le sujet n'en est pas un -> pas une incise
def _classify_subject(subj: str, idx: dict[str, str]):
"""Locuteur porte par le sujet d'une incise nominale.
"""Locuteur NOMME porte par le sujet d'une incise nominale.
- personnage connu -> nom canonique ;
- nom propre (capitalise) inconnu -> nom de surface (seed quand meme : le
texte le nomme, independamment de la fiabilite de l'extraction) ;
- nom de role generique ("le soldat") -> None (incise reelle, pas de seed) ;
- nom de role ("le soldat") -> None : pas un locuteur NOMME. L'incise reste
detectee (narration), et le rattachement a un anonyme (par genre/age) se
fait en post-traitement (cf. `_apply_anonymous_speakers` / `incise_role`) ;
- mot quelconque -> _REJECT (pas une incise).
"""
low = subj.lower()
@@ -464,11 +727,14 @@ def _classify_subject(subj: str, idx: dict[str, str]):
return _REJECT
def _nominal_matches(text: str, names) -> list[tuple[int, int, Optional[str]]]:
"""Passe 2 : (start, end, locuteur) pour chaque incise nominale.
def _nominal_matches(text: str, names
) -> list[tuple[int, int, Optional[str], str]]:
"""Passe 2 : (start, end, locuteur, sujet) pour chaque incise nominale.
Une incise nominale = verbe de parole + sujet (nom du casting, nom propre,
ou nom de role). Le sujet nom propre est seede meme absent du casting.
Le 4e champ est le sujet (minuscule) : sert a reconnaitre un nom de role
(`incise_role`) pour rattacher un locuteur anonyme par genre/age.
"""
idx = _name_token_index(names)
literals = sorted(set(idx) | _ROLE_NOUNS, key=len, reverse=True)
@@ -486,13 +752,15 @@ def _nominal_matches(text: str, names) -> list[tuple[int, int, Optional[str]]]:
r"[^.!?…»\",;]*?)"
r"(?P<close>[.!?…,])",
)
out: list[tuple[int, int, Optional[str]]] = []
out: list[tuple[int, int, Optional[str], str]] = []
for m in pat.finditer(text):
spk = _classify_subject(m.group("subj"), idx)
subj = m.group("subj")
spk = _classify_subject(subj, idx)
if spk is _REJECT:
continue
out.append((m.start("inc"),
_incise_end(text, m.end("close"), m.group("lead")), spk))
_incise_end(text, m.end("close"), m.group("lead")),
spk, subj.lower()))
return out
@@ -511,18 +779,33 @@ def _merge_spans(spans: list[tuple[int, int]]) -> list[Incise]:
def detect_incises(text: str, *, names=None) -> list[Incise]:
"""Bornes des incises dans une replique (inversion + nominale cast-aware)."""
spans = _inversion_spans(text)
spans += [(s, e) for s, e, _ in _nominal_matches(text, names or set())]
spans += [(s, e) for s, e, _, _ in _nominal_matches(text, names or set())]
return _merge_spans(spans)
def incise_speaker(text: str, incise: Incise, names) -> Optional[str]:
"""Locuteur explicite porte par une incise nominale ("compatit Holden")."""
for s, e, spk in _nominal_matches(text, names):
"""Locuteur NOMME explicite porte par une incise nominale ("compatit Holden").
None pour une incise de role ("informa le soldat") : un role n'est pas un
locuteur nomme (cf. `incise_role` pour le rattachement anonyme).
"""
for s, e, spk, _ in _nominal_matches(text, names):
if s == incise.start and e == incise.end:
return spk
return None
def incise_role(text: str, incise: Incise, names) -> Optional[str]:
"""Nom de role (minuscule) sujet d'une incise ("informa le soldat" -> "soldat").
Renvoie None si l'incise n'est pas une incise de role. Sert a rattacher la
replique a un locuteur anonyme regroupe par genre/age (cf. `_anon_identity`)."""
for s, e, _spk, subj in _nominal_matches(text, names):
if s == incise.start and e == incise.end and subj in _ROLE_NOUNS:
return subj
return None
def iter_incise_pieces(
text: str, incises: list[Incise]
) -> list[tuple[bool, str]]:
@@ -552,10 +835,10 @@ def iter_incise_pieces(
def analyze_chapter(
chapter: Chapter,
ct: ChapterText,
gemma: Gemma,
gemma: LLM,
*,
book_chars: Optional[list[Character]] = None,
dedup_gemma: Optional[Gemma] = None,
dedup_gemma: Optional[LLM] = None,
) -> tuple[ChapterAnalysis, list[Character]]:
"""Analyse complete d'un chapitre.
@@ -594,12 +877,18 @@ def analyze_chapter(
# Annotation deterministe des incises (bornes, non destructif) + seeding :
# une incise nominale qui nomme un personnage fixe le locuteur avec certitude
# AVANT l'appel LLM (corrige les cas que le petit modele rate).
# NB: ne PAS inclure les alias ici -> mesure : ca change trop le prompt et
# provoque de gros effets papillon (ch06 12B: 96% -> 80%). Les epithetes sont
# rattaches en post-traitement par la canonicalisation (sur le cast complet).
names = {c.name for c in chars}
for seg in segments:
if seg.type is not SegmentType.DIALOGUE:
continue
seg.incises = detect_incises(seg.text, names=names)
for inc in seg.incises:
# PRE-LLM : seuls les noms propres seedent (les incises de role
# renvoient None -> pas de seed, donc prompt inchange ; les roles
# sont rattaches en anonymes en post-traitement, sans effet papillon).
spk = incise_speaker(seg.text, inc, names)
if spk:
seg.speaker = spk
@@ -611,6 +900,22 @@ def analyze_chapter(
_refine_unknown_speakers(segments, gemma, characters=chapter_chars,
confidence=conf)
# Post-traitement deterministe (sans LLM). Ordre important :
# 1. rattache les incises de role a un locuteur anonyme par genre/age ;
# 2. repare l'alternance des tours dans les echanges a deux ;
# 3. recolle les variantes de noms au canonique du cast (rendu + score) ;
# 4. resout les figurants restes indetermines via la narration adjacente.
anon = _apply_anonymous_speakers(segments, names=names)
_repair_alternation(segments, names=names)
_canonicalize_speakers(segments, chars)
anon.update(_resolve_anonymous_figurants(segments))
# Cree les Character generiques des buckets anonymes (assignation de voix).
known = {c.name for c in chars}
for ident, (gender, age) in anon.items():
if ident not in known:
chars.append(Character(name=ident, gender=gender, age=age))
known.add(ident)
# Absorbe les locuteurs residuels (hors liste) en aliases (heuristique seule).
chars, _ = reconcile_characters(
chars, [], None, speaker_names=[s.speaker for s in segments])