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

@@ -132,12 +132,15 @@ def _absorb(
age: Optional[str] = None,
description: Optional[str] = None,
voice_id: Optional[str] = None,
keep_canonical: bool = False,
) -> None:
"""Fusionne la variante `name` dans `target` (mutation en place).
Enrichit les attributs manquants, recalcule le nom canonique et range les
autres formes en aliases.
"""
autres formes en aliases. `keep_canonical=True` GARDE le nom actuel de
`target` comme canonique (les autres formes deviennent aliases) : sert a
rendre stable un nom deja etabli dans le cast (un chapitre ne doit pas
renommer "Sagale" en "Amiral Mehmet Sagale")."""
target.gender = target.gender or gender
target.age = target.age or age
target.description = target.description or description
@@ -148,17 +151,36 @@ def _absorb(
f = (f or "").strip()
if f:
forms.setdefault(_norm(f), f)
canon = max(forms, key=lambda n: _completeness(forms[n]))
canon = (_norm(target.name) if keep_canonical
else max(forms, key=lambda n: _completeness(forms[n])))
target.name = forms[canon]
target.aliases = sorted(v for k, v in forms.items() if k != canon)
# Genre/age d'un locuteur anonyme "anonyme (homme, adulte)" (inverse de
# segmenter._anon_identity) -> pour qu'il herite d'une voix du bon genre.
_ANON_GENDER = {"homme": "male", "femme": "female"}
_ANON_AGE = {"enfant": "child", "jeune": "young", "adulte": "adult", "vieux": "old"}
def _anon_attrs(name: str) -> tuple[Optional[str], Optional[str]]:
low = name.strip().lower()
if not low.startswith("anonyme"):
return None, None
inside = low[low.find("(") + 1: low.find(")")] if "(" in low else ""
toks = [t.strip() for t in inside.split(",")]
gender = next((_ANON_GENDER[t] for t in toks if t in _ANON_GENDER), None)
age = next((_ANON_AGE[t] for t in toks if t in _ANON_AGE), None)
return gender, age
def _item(c) -> dict:
"""Normalise un personnage ou un nom brut en entree de reconciliation."""
if isinstance(c, Character):
return {"name": c.name, "gender": c.gender, "age": c.age,
"description": c.description, "voice_id": c.voice_id}
return {"name": str(c), "gender": None, "age": None,
gender, age = _anon_attrs(str(c)) # figurant anonyme -> genre/age depuis le nom
return {"name": str(c), "gender": gender, "age": age,
"description": None, "voice_id": None}
@@ -194,6 +216,9 @@ def reconcile_characters(
"""
chars = [c.model_copy(deep=True) for c in book_chars]
name_map: dict[str, str] = {}
# Noms deja etablis dans le cast : on les garde canoniques (un chapitre ne
# doit pas renommer un personnage existant en une forme plus longue/titree).
established = {_norm(c.name) for c in book_chars}
items = [_item(c) for c in new_chars]
seen = {_norm(it["name"]) for it in items}
@@ -214,7 +239,8 @@ def reconcile_characters(
pending.append(it)
elif m is not None:
_absorb(m, it["name"], gender=it["gender"], age=it["age"],
description=it["description"], voice_id=it["voice_id"])
description=it["description"], voice_id=it["voice_id"],
keep_canonical=_norm(m.name) in established)
name_map[_norm(it["name"])] = m.name
elif gemma is not None:
pending.append(it) # peut etre une variante non evidente ("Jim")
@@ -231,7 +257,8 @@ def reconcile_characters(
target = hm if isinstance(hm, Character) else None
if target is not None:
_absorb(target, it["name"], gender=it["gender"], age=it["age"],
description=it["description"], voice_id=it["voice_id"])
description=it["description"], voice_id=it["voice_id"],
keep_canonical=_norm(target.name) in established)
name_map[_norm(it["name"])] = target.name
else:
_create(chars, it, name_map)