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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user