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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user