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

@@ -53,14 +53,16 @@ def analyze(
chapter: Optional[int] = typer.Option(None, help="Index de chapitre unique (def: tous)."),
limit: Optional[int] = typer.Option(None, help="Limiter au N premiers chapitres rendus."),
force: bool = typer.Option(False, help="Re-analyser meme si un artefact existe."),
backend: Optional[str] = typer.Option(None, help="Moteur LLM: mlx ou lmstudio (def: reglages)."),
model: Optional[str] = typer.Option(None, help="Identifiant de modele (def: reglages)."),
):
"""Analyse Gemma : segments narration/dialogue + locuteurs + casting."""
from .analysis.gemma import Gemma
from .analysis.llm.client import LLM
from .analysis.segmenter import analyze_chapter
from .settings import get_settings
book = load_book(slug)
gemma = Gemma()
gemma = LLM(model_id=model, backend=backend)
dedup_gemma = gemma if get_settings().dedup_use_gemma else None
cast = artifacts.load_cast(slug)
chars = list(cast.characters)
@@ -100,6 +102,8 @@ def benchmark(
slug: str,
models: Optional[str] = typer.Option(
None, help="Modeles a comparer, separes par des virgules (def: modele courant)."),
backend: Optional[str] = typer.Option(
None, help="Moteur LLM: mlx ou lmstudio (def: reglages)."),
chapter: Optional[int] = typer.Option(
None, help="Restreindre a un chapitre (def: tous ceux avec reference)."),
temperature: Optional[float] = typer.Option(
@@ -115,12 +119,16 @@ def benchmark(
import sys
from datetime import datetime
from .analysis import gemma as _gemma
from .analysis.llm import client as _llm
from .analysis.benchmark import run_benchmark
from .settings import get_settings
settings = get_settings()
backend_name = backend or settings.gemma_backend
default_model = (settings.lmstudio_model if backend_name == "lmstudio"
else settings.gemma_model)
model_ids = ([m.strip() for m in models.split(",") if m.strip()]
if models else [get_settings().gemma_model])
if models else [default_model])
chapters = [chapter] if chapter is not None else None
label = "artefacts en cache" if use_cached else f"{len(model_ids)} modele(s)"
@@ -137,17 +145,17 @@ def benchmark(
def _sink(piece: str) -> None:
sys.stdout.write(piece)
sys.stdout.flush()
_gemma.set_token_sink(_sink)
_llm.set_token_sink(_sink)
try:
report = run_benchmark(
slug, model_ids, chapters=chapters,
slug, model_ids, backend=backend_name, chapters=chapters,
temperature=temperature,
reasoning=reasoning if reasoning else None,
use_cached=use_cached,
progress=_progress)
finally:
if stream:
_gemma.set_token_sink(None)
_llm.set_token_sink(None)
report.generated_at = datetime.now().isoformat(timespec="seconds")
# Table comparative : une ligne par modele (agregat micro-moyenne).
@@ -197,9 +205,11 @@ def benchmark(
def pronounce(
slug: str,
chapter: Optional[int] = typer.Option(None, help="Index de chapitre (def: 1er rendu)."),
backend: Optional[str] = typer.Option(None, help="Moteur LLM: mlx ou lmstudio (def: reglages)."),
model: Optional[str] = typer.Option(None, help="Identifiant de modele (def: reglages)."),
):
"""Propose des candidats de prononciation (Gemma) -> pronunciation.json."""
from .analysis.gemma import Gemma
from .analysis.llm.client import LLM
from .analysis.pronunciation import merge_pronunciations, propose_pronunciations
book = load_book(slug)
@@ -209,7 +219,7 @@ def pronounce(
console.print("[red]Chapitre introuvable.[/]"); raise typer.Exit(1)
ct = load_chapter_text(slug, ch)
gemma = Gemma()
gemma = LLM(model_id=model, backend=backend)
with console.status("Recherche des mots a risque…"):
new = propose_pronunciations("\n".join(ct.paragraphs), gemma)
pron = merge_pronunciations(artifacts.load_pronunciation(slug), new)
@@ -228,6 +238,8 @@ def cast(
rebuild_voicebank: bool = typer.Option(False, help="Regenere les clips de la voicebank."),
dedup: bool = typer.Option(False, help="Deduplique d'abord les variantes de noms (heuristique)."),
llm: bool = typer.Option(False, "--llm", help="Ajoute la passe Gemma a la dedup (moins sur)."),
backend: Optional[str] = typer.Option(None, help="Moteur LLM pour --llm: mlx ou lmstudio (def: reglages)."),
model: Optional[str] = typer.Option(None, help="Identifiant de modele pour --llm (def: reglages)."),
):
"""Construit la voicebank (si besoin) et auto-assigne les voix au casting."""
from .casting.assign import assign_voices
@@ -243,8 +255,8 @@ def cast(
from .models import Cast
gemma = None
if llm:
from .analysis.gemma import Gemma
gemma = Gemma()
from .analysis.llm.client import LLM
gemma = LLM(model_id=model, backend=backend)
before = len(cast.characters)
with console.status("Deduplication du casting…"):
chars = dedup_cast(cast.characters, gemma)