Initial commit: InkFlow — EPUB vers livre audio local (MLX/Kokoro)
This commit is contained in:
86
backend/inkflow/casting/assign.py
Normal file
86
backend/inkflow/casting/assign.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""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.
|
||||
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 (FR native).
|
||||
PREFERRED_NARRATOR = "fr_f_siwis"
|
||||
|
||||
|
||||
def _pick_pool(vb: Voicebank, gender: Optional[str], narrator_id: str) -> list[str]:
|
||||
"""Voix candidates : on privilegie STRICTEMENT le genre (quitte a reutiliser).
|
||||
|
||||
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.
|
||||
"""
|
||||
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]
|
||||
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)
|
||||
# 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
|
||||
Reference in New Issue
Block a user