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

@@ -1,10 +1,12 @@
"""Auto-casting : attribue une voix distincte a chaque personnage.
Strategie deterministe :
- Narrateur : voix FR native par defaut (ff_siwis), sinon premiere voix.
- Personnages : voix du meme genre, distinctes tant qu'il en reste ; au-dela on
recycle en repartissant le plus equitablement possible. Genre inconnu -> pool
mixte. L'ordre (tri par nom) garantit la reproductibilite.
- Narrateur : voix dediee de la voicebank (PREFERRED_NARRATOR), sinon 1re voix.
- Personnages nommes : voix du meme genre dans le pool *nomme* (anonymous=False),
distinctes tant qu'il en reste ; au-dela recyclage equitable.
- Figurants anonymes ("anonyme (...)") : voix du meme genre dans le pool *reserve*
(anonymous=True), pour ne pas consommer les voix des personnages nommes.
Genre inconnu -> pool mixte. L'ordre (tri par nom) garantit la reproductibilite.
L'utilisateur pourra surcharger ces choix dans l'UI.
"""
from __future__ import annotations
@@ -14,18 +16,29 @@ from typing import Optional
from ..models import Cast, Character, Voicebank
# Voix narrateur preferee (FR native).
PREFERRED_NARRATOR = "fr_f_siwis"
# Voix narrateur preferee (voix dediee de la voicebank CML).
PREFERRED_NARRATOR = "fr_narrator"
def _pick_pool(vb: Voicebank, gender: Optional[str], narrator_id: str) -> list[str]:
"""Voix candidates : on privilegie STRICTEMENT le genre (quitte a reutiliser).
def _is_anonymous(name: str) -> bool:
"""Un figurant anonyme ("anonyme (homme)", "anonyme (femme, vieux)", ...)."""
return name.strip().lower().startswith("anonyme")
On ne croise le genre que si aucune voix du bon genre n'existe. Le narrateur
est exclu tant qu'il reste d'autres options, pour le distinguer.
def _pick_pool(vb: Voicebank, gender: Optional[str], narrator_id: str,
*, anonymous: bool) -> list[str]:
"""Voix candidates : genre STRICT et pool reserve selon `anonymous`.
Les figurants anonymes tirent dans le sous-ensemble `anonymous=True`, les
personnages nommes dans le sous-ensemble `anonymous=False` — les deux ne se
melangent pas. On ne croise (tag puis genre) qu'en dernier recours si le pool
cible est vide. Le narrateur est exclu tant qu'il reste d'autres options.
"""
same = [e.id for e in vb.by_gender(gender)] if gender in ("male", "female") else []
pool = same if same else [e.id for e in vb.entries]
genders = (gender,) if gender in ("male", "female") else ("male", "female")
# 1) genre + tag exacts ; 2) genre seul ; 3) tout.
same_tag = [e.id for g in genders for e in vb.by_gender(g, anonymous=anonymous)]
same_gender = [e.id for g in genders for e in vb.by_gender(g)]
pool = same_tag or same_gender or [e.id for e in vb.entries]
non_narrator = [vid for vid in pool if vid != narrator_id]
return non_narrator or pool # garde le narrateur seulement s'il est seul
@@ -55,7 +68,7 @@ def assign_voices(
if respect_existing and ch.voice_id and vb.by_id(ch.voice_id):
usage[ch.voice_id] += 1
continue # respecte une attribution existante (override utilisateur)
pool = _pick_pool(vb, ch.gender, narrator_id)
pool = _pick_pool(vb, ch.gender, narrator_id, anonymous=_is_anonymous(ch.name))
# Choisit la voix la moins utilisee du pool (donc une voix neuve d'abord).
best = min(pool, key=lambda vid: (usage[vid], pool.index(vid)))
ch.voice_id = best

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)

View File

@@ -1,9 +1,14 @@
"""Banque de voix : un jeu de voix variees (genre/age) auto-suffisant.
"""Banque de voix : un jeu de voix francaises variees (genre, pool anonyme).
Chaque voix s'appuie sur une voix Kokoro (identite + clip de reference). Le clip
de reference est genere une fois en lisant un passage francais standard ; il sert
de reference de timbre pour le clonage Qwen3 (rendu final). Aucune ressource
externe a sourcer.
La banque de reference est peuplee par `scripts/import_voices.py` a partir de
**vrais clips de locuteurs francais** (CML-TTS, livres audio) : chaque voix a son
`ref_audio` + `ref_text`, qui servent de reference de timbre au clonage Qwen3
(rendu final). C'est la source de verite (metadata.json versionne).
`build_voicebank()` ci-dessous est un fallback **legacy** : il regenere des clips
*avec Kokoro* (presets a timbre anglais lisant du francais -> accent). Il ne se
declenche que si metadata.json est absent ou sans `ref_audio`. Re-peupler la
banque = relancer le script d'import, pas ce fallback.
Resolution moteur :
- Kokoro -> VoiceSpec(preset=kokoro_voice) (rapide, preview / draft)