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

@@ -20,7 +20,7 @@ from pydantic import BaseModel
from ..config import DATA_DIR, book_data_dir, book_output_dir, ensure_dirs
from ..epub.parser import load_book, load_chapter_text, parse_epub
from ..models import Cast, ChapterAnalysis, Pronunciation
from ..models import Cast, ChapterAnalysis, Character, Pronunciation
from ..pipeline.orchestrator import load_state, orchestrator
from ..settings import Settings, get_settings, save_settings
from ..store import artifacts
@@ -196,6 +196,43 @@ def put_cast(slug: str, cast: Cast) -> dict:
return {"saved": True}
@app.get("/api/books/{slug}/cast/unresolved")
def get_unresolved_speakers(slug: str) -> dict:
"""Locuteurs apparaissant dans l'analyse mais rattaches a aucun personnage.
Surface les surfaces que la canonicalisation deterministe a refuse de
trancher, pour que l'utilisateur les aliase/fusionne a la main. Predicat =
rattachement a un Character (par nom/alias exact ou heuristique), independant
de l'assignation de voix."""
from ..casting.dedup import heuristic_match
from ..epub.parser import load_book
_require(slug)
cast = artifacts.load_cast(slug)
def resolves(spk: str) -> bool:
low = spk.lower()
for ch in cast.characters:
if ch.name.lower() == low or low in (a.lower() for a in ch.aliases):
return True
return isinstance(heuristic_match(spk, cast.characters), Character)
agg: dict[str, dict] = {}
for ch in load_book(slug).chapters:
if not artifacts.analysis_path(slug, ch.index).exists():
continue
for seg in artifacts.load_analysis(slug, ch.index).segments:
spk = (seg.speaker or "").strip()
if not spk or spk.lower() in {"narrateur", "inconnu", "?"}:
continue
if resolves(spk):
continue
row = agg.setdefault(spk, {"speaker": spk, "count": 0, "chapters": []})
row["count"] += 1
if ch.index not in row["chapters"]:
row["chapters"].append(ch.index)
return {"unresolved": sorted(agg.values(), key=lambda r: -r["count"])}
@app.get("/api/books/{slug}/pronunciation")
def get_pron(slug: str) -> dict:
_require(slug)
@@ -222,6 +259,16 @@ def write_settings(settings: Settings) -> dict:
return {"saved": True}
@app.get("/api/lmstudio/models")
def list_lmstudio_models() -> dict:
"""Modeles telecharges dans LM Studio (pour peupler le selecteur de l'UI)."""
from ..analysis.llm.lmstudio_backend import list_models
try:
return {"models": list_models(get_settings().lmstudio_base_url)}
except Exception as exc: # noqa: BLE001 — serveur down / injoignable
raise HTTPException(503, f"LM Studio injoignable: {exc}")
# --- Voicebank + preview -----------------------------------------------------
@app.get("/api/voicebank")