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

@@ -7,15 +7,33 @@ Deux modes :
"""
from __future__ import annotations
import logging
import numpy as np
from ..settings import get_settings
from .base import TTSBackend, VoiceSpec, to_mono_float32
from .chunk import chunk_text
logger = logging.getLogger(__name__)
# Qwen3 tolere des sequences plus longues que Kokoro, mais on borne quand meme.
_QWEN_MAX_CHARS = 500
# Garde-fou anti-derive : Qwen3 part parfois en boucle (audio 50x trop long) ou
# s'arrete net (sortie ~0 s). On estime la duree plausible d'un chunk depuis sa
# longueur (~15 caracteres/s en francais) et on rejette/reessaie les sorties hors
# bornes. Stochastique (temperature) -> un retry change le tirage.
_CHARS_PER_SEC = 15.0
_QWEN_RETRIES = 3
_MIN_FLOOR_SEC = 0.3 # en deca = generation echouee (silence)
def _bounds(n_chars: int) -> tuple[float, float, float]:
"""(attendu, min, max) en secondes pour un chunk de `n_chars` caracteres."""
expected = max(1.0, n_chars / _CHARS_PER_SEC)
return expected, max(_MIN_FLOOR_SEC, 0.4 * expected), 2.5 * expected + 2.0
class Qwen3Backend(TTSBackend):
name = "qwen3"
@@ -45,14 +63,46 @@ class Qwen3Backend(TTSBackend):
kwargs["voice"] = voice.preset or get_settings().qwen3_default_voice
return kwargs
def _gen_chunk_once(self, chunk: str, kwargs: dict) -> np.ndarray:
"""Genere l'audio (concatene) d'un chunk en un tirage."""
out: list[np.ndarray] = []
for result in self._model.generate(text=chunk, **kwargs):
self._sample_rate = getattr(result, "sample_rate", self._sample_rate)
out.append(to_mono_float32(result.audio))
return np.concatenate(out) if out else np.zeros(0, dtype=np.float32)
def _gen_chunk_guarded(self, chunk: str, kwargs: dict) -> np.ndarray:
"""Genere un chunk en rejetant les sorties aberrantes (boucle / coupure).
Retourne le 1er tirage dans les bornes ; sinon la tentative la plus proche
de la duree attendue (en excluant les silences et les derives extremes).
"""
sr = self._sample_rate
expected, lo, hi = _bounds(len(chunk))
attempts: list[np.ndarray] = []
for i in range(_QWEN_RETRIES):
audio = self._gen_chunk_once(chunk, kwargs)
dur = len(audio) / sr
if lo <= dur <= hi:
if i:
logger.info("Qwen3: chunk OK au retry %d (%.1fs)", i, dur)
return audio
logger.warning("Qwen3: sortie aberrante %.1fs (attendu ~%.1fs) — retry", dur, expected)
attempts.append(audio)
# Aucune tentative dans les bornes : on garde la moins mauvaise (ni
# silence ni derive), la plus proche de l'attendu.
valid = [a for a in attempts if _MIN_FLOOR_SEC <= len(a) / sr <= hi] or attempts
best = min(valid, key=lambda a: abs(len(a) / sr - expected))
logger.warning("Qwen3: chunk non stabilise apres %d essais, garde %.1fs: %r",
_QWEN_RETRIES, len(best) / sr, chunk[:60])
return best
def synthesize(self, text: str, voice: VoiceSpec) -> tuple[np.ndarray, int]:
self._ensure_loaded()
kwargs = self._gen_kwargs(voice)
pieces: list[np.ndarray] = []
for chunk in chunk_text(text, max_chars=_QWEN_MAX_CHARS):
for result in self._model.generate(text=chunk, **kwargs):
self._sample_rate = getattr(result, "sample_rate", self._sample_rate)
pieces.append(to_mono_float32(result.audio))
pieces = [self._gen_chunk_guarded(chunk, kwargs)
for chunk in chunk_text(text, max_chars=_QWEN_MAX_CHARS)]
pieces = [p for p in pieces if len(p)]
if not pieces:
return np.zeros(0, dtype=np.float32), self._sample_rate
return np.concatenate(pieces), self._sample_rate