Files
colgora ba1813c583 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
2026-06-21 21:32:31 +02:00

100 lines
4.1 KiB
Python

"""Auto-casting : attribue une voix distincte a chaque personnage.
Strategie deterministe :
- 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
from collections import Counter
from typing import Optional
from ..models import Cast, Character, Voicebank
# Voix narrateur preferee (voix dediee de la voicebank CML).
PREFERRED_NARRATOR = "fr_narrator"
def _is_anonymous(name: str) -> bool:
"""Un figurant anonyme ("anonyme (homme)", "anonyme (femme, vieux)", ...)."""
return name.strip().lower().startswith("anonyme")
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.
"""
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
def assign_voices(
characters: list[Character],
vb: Voicebank,
*,
narrator_voice_id: Optional[str] = None,
respect_existing: bool = False,
) -> Cast:
"""Renvoie un Cast avec narrateur + voix par personnage (mutation des chars).
`respect_existing=True` conserve les voix deja attribuees (overrides UI) ;
sinon tout est re-calcule (auto-casting frais).
"""
if not vb.entries:
return Cast(narrator_voice_id=narrator_voice_id, characters=characters)
narrator_id = narrator_voice_id or (
PREFERRED_NARRATOR if vb.by_id(PREFERRED_NARRATOR) else vb.entries[0].id)
usage: Counter[str] = Counter()
usage[narrator_id] += 1 # le narrateur compte deja
for ch in sorted(characters, key=lambda c: c.name.lower()):
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, 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
usage[best] += 1
return Cast(narrator_voice_id=narrator_id, characters=characters)
def resolve_speaker_voice(
speaker: str, cast: Cast, vb: Voicebank
) -> Optional[str]:
"""Mappe un nom de locuteur (segment) vers un id de voix.
Matche d'abord par nom/alias exact (rapide), puis en dernier recours par
rapprochement heuristique de tokens (ex: un "Jim" qui n'aurait pas encore
ete absorbe comme alias de "James Holden").
"""
if speaker == "narrateur":
return cast.narrator_voice_id
low = speaker.lower()
for ch in cast.characters:
if ch.name.lower() == low or low in (a.lower() for a in ch.aliases):
return ch.voice_id
from .dedup import heuristic_match
match = heuristic_match(speaker, cast.characters)
if isinstance(match, Character):
return match.voice_id
return None # inconnu -> le rendu repliera sur le narrateur