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

@@ -0,0 +1,14 @@
"""Client LLM pluggable pour l'analyse de texte (attribution, personnages...).
La facade `LLM` (client.py) expose `generate` / `generate_json` consommes par
tout le pipeline. Sous elle, un backend pluggable (`base.LLMBackend`) transforme
des messages en texte brut : `mlx_backend` (mlx-lm, defaut) ou `lmstudio_backend`
(API OpenAI locale de LM Studio, sert GGUF *et* MLX). Selection par nom via
`factory.get_llm_backend`.
"""
from __future__ import annotations
from .client import LLM, set_token_sink
from .factory import get_llm_backend, reset_llm_cache
__all__ = ["LLM", "set_token_sink", "get_llm_backend", "reset_llm_cache"]

View File

@@ -0,0 +1,74 @@
"""Helpers agnostiques du moteur : extraction JSON tolerante + retrait de la
chaine de pensee des modeles a raisonnement.
Module neutre (aucune dependance a un backend) : partage par la facade `LLM` et
par les backends (qui s'en servent pour l'arret anticipe en streaming), sans
creer de cycle d'import.
"""
from __future__ import annotations
import json
import re
from typing import Any
# Bornes d'un bloc JSON dans une reponse potentiellement bavarde.
_JSON_SPAN_RE = re.compile(r"(\{.*\}|\[.*\])", re.DOTALL)
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
# Marqueurs de FIN de chaine de pensee : on ne garde que ce qui suit le dernier.
# - balises type DeepSeek-R1 / Qwen-think
# - format a canaux type Gemma 4 / Harmony (la pensee est close par <channel|>)
_REASONING_END_MARKERS = ("</think>", "<channel|>", "<|channel|>")
# Prefixe de canal/think non ferme reste en tete (pensee tronquee) : a retirer.
_REASONING_OPEN_RE = re.compile(r"^\s*(?:<\|?channel\|?>\s*\w*|<think>)", re.IGNORECASE)
def _strip_reasoning(text: str) -> str:
"""Retire la chaine de pensee d'un modele a raisonnement.
Ne conserve que ce qui suit le dernier marqueur de fin de pensee
(`</think>`, `<channel|>`...). Si seul un marqueur d'ouverture non ferme
subsiste (pensee tronquee par le budget de tokens), on le retire en tete
pour eviter de parser la pensee a la place de la reponse.
"""
t = text
for marker in _REASONING_END_MARKERS:
if marker in t:
t = t.rsplit(marker, 1)[-1]
t = _REASONING_OPEN_RE.sub("", t)
return t.strip()
def _has_complete_json(text: str) -> bool:
"""True si `text` contient deja un objet/array JSON complet et parsable.
Sert a stopper la generation des modeles a raisonnement des que la reponse
finale est ecrite (evite de consumer le budget en boucles de pensee).
"""
try:
_extract_json(text)
return True
except Exception: # noqa: BLE001
return False
def _extract_json(text: str) -> Any:
"""Extrait le premier objet/array JSON d'une reponse libre du modele.
Tolere le texte parasite avant/apres (y compris un 2e bloc) grace a
raw_decode, qui s'arrete au premier JSON complet.
"""
text = text.strip()
fence = _FENCE_RE.search(text)
if fence:
text = fence.group(1).strip()
decoder = json.JSONDecoder()
# Cherche le 1er debut de structure JSON et decode a partir de la.
for i, ch in enumerate(text):
if ch in "[{":
try:
obj, _ = decoder.raw_decode(text[i:])
return obj
except json.JSONDecodeError:
continue
raise ValueError("aucun JSON trouve dans la reponse")

View File

@@ -0,0 +1,43 @@
"""Abstraction des moteurs LLM (backend pluggable).
Calque du pattern TTS (`tts/base.py`) : un backend ne fait *qu'une* chose,
transformer une liste de messages (role/content) en texte brut. Toute la logique
agnostique (calcul des parametres depuis les Settings, retrait de la pensee,
extraction JSON tolerante, retries) vit dans la facade `client.LLM`, jamais
dupliquee par backend.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Callable, Optional
class LLMBackend(ABC):
"""Interface commune a tous les moteurs LLM."""
name: str = "base"
def __init__(self, model_ref: str):
# Reference du modele, interpretee par le backend : id mlx-community
# (mlx) ou nom du modele charge dans LM Studio (lmstudio, vide -> actif).
self.model_ref = model_ref
@abstractmethod
def complete(
self,
messages: list[dict],
*,
max_tokens: int,
temperature: float,
reasoning: bool,
token_sink: Optional[Callable[[str], None]] = None,
) -> str:
"""Genere et renvoie le texte BRUT (chaine de pensee incluse).
- `messages` : liste {role, content} (system optionnel + user).
- `reasoning` : si vrai, le modele peut emettre une chaine de pensee ;
le backend peut s'arreter des que le JSON post-pensee est complet. La
facade retire la pensee en aval (`_strip_reasoning`).
- `token_sink` : si fourni, appele avec chaque morceau de texte au fil de
la generation (streaming pour `inkflow benchmark --stream`).
"""

View File

@@ -0,0 +1,119 @@
"""Facade LLM pour l'analyse de texte (anciennement `Gemma`).
Charge un backend pluggable (mlx par defaut, ou LM Studio) selon les reglages et
expose `generate` / `generate_json` consommes par tout le pipeline. Toute la
logique agnostique du moteur vit ici : calcul des parametres depuis les Settings,
retrait de la chaine de pensee (modeles a raisonnement) et `generate_json`
tolerant qui extrait le premier objet/array JSON valide de la sortie du modele.
"""
from __future__ import annotations
from typing import Any, Optional
from ...settings import Settings, get_settings
from ._text import _extract_json, _strip_reasoning
from .factory import get_llm_backend
# Hook de streaming optionnel. Si defini, `generate()` diffuse chaque morceau de
# texte AU FIL de la generation (pensee comprise, avant tout nettoyage) en
# appelant ce callback. Utilise par `inkflow benchmark --stream` pour voir les
# tokens en temps reel. None -> generation par lot classique (plus rapide).
_TOKEN_SINK: Optional[Any] = None
def set_token_sink(callback) -> None:
"""Definit (ou retire avec None) le callback de streaming des tokens."""
global _TOKEN_SINK
_TOKEN_SINK = callback
def _model_ref_for(backend: str, settings: Settings) -> str:
"""Reference de modele par defaut pour un backend donne."""
if backend == "lmstudio":
return settings.lmstudio_model
return settings.gemma_model
class LLM:
"""Petite facade multi-backend pour piloter le LLM d'analyse."""
def __init__(self, model_id: Optional[str] = None, backend: Optional[str] = None):
settings = get_settings()
self.backend_name = backend or settings.gemma_backend
self.model_ref = model_id or _model_ref_for(self.backend_name, settings)
self._backend = None
def _ensure_loaded(self) -> None:
if self._backend is None:
self._backend = get_llm_backend(self.backend_name, self.model_ref)
def generate(
self,
prompt: str,
*,
system: Optional[str] = None,
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
) -> str:
"""Genere une reponse texte a partir d'un prompt (template de chat).
`max_tokens`/`temperature` non fournis -> valeurs des reglages courants.
"""
self._ensure_loaded()
settings = get_settings()
if max_tokens is None:
max_tokens = settings.gemma_max_tokens
# En mode raisonnement, plafond dedie (garde-fou anti-boucle) ; la
# generation s'arrete de toute facon des que le JSON post-pensee est
# complet (cf. arret anticipe des backends).
if settings.gemma_reasoning:
max_tokens = max(max_tokens, settings.gemma_reasoning_max_tokens)
if temperature is None:
temperature = settings.gemma_temperature
# Decodage glouton (temp 0) + raisonnement = boucles de pensee sans fin.
# On force un echantillonnage minimal en mode raisonnement.
if settings.gemma_reasoning and temperature == 0.0:
temperature = settings.gemma_reasoning_temperature
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
raw = self._backend.complete(
messages,
max_tokens=max_tokens,
temperature=temperature,
reasoning=settings.gemma_reasoning,
token_sink=_TOKEN_SINK,
)
# Retire la chaine de pensee des modeles a raisonnement (sinon des
# fragments de la "pensee" parasitent l'extraction JSON en aval).
if settings.gemma_reasoning:
return _strip_reasoning(raw)
return raw
def generate_json(
self,
prompt: str,
*,
system: Optional[str] = None,
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
retries: int = 1,
) -> Any:
"""Genere puis parse un JSON. Reessaie en cas d'echec de parsing.
`max_tokens`/`temperature` non fournis -> valeurs des reglages courants.
"""
last_err: Optional[Exception] = None
for attempt in range(retries + 1):
raw = self.generate(
prompt, system=system, max_tokens=max_tokens,
temperature=temperature if attempt == 0 else 0.0,
)
try:
return _extract_json(raw)
except Exception as exc: # noqa: BLE001
last_err = exc
raise ValueError(f"Reponse JSON invalide apres {retries + 1} essais: {last_err}")

View File

@@ -0,0 +1,36 @@
"""Selection du backend LLM par nom (pluggable).
Calque de `tts/factory.py` : cache par (nom, reference de modele). Une
sauvegarde des reglages (settings.save_settings) appelle `reset_llm_cache()`
pour que les changements de backend/modele prennent effet sans redemarrage.
"""
from __future__ import annotations
from functools import lru_cache
from .base import LLMBackend
BACKENDS = ("mlx", "lmstudio")
@lru_cache(maxsize=4)
def get_llm_backend(backend: str = "mlx", model_ref: str = "") -> LLMBackend:
backend = backend.lower()
if backend == "mlx":
from .mlx_backend import MLXBackend
return MLXBackend(model_ref)
if backend == "lmstudio":
from .lmstudio_backend import LMStudioBackend
return LMStudioBackend(model_ref)
raise ValueError(
f"Backend LLM inconnu: {backend!r} (dispo: {', '.join(BACKENDS)})")
def reset_llm_cache() -> None:
"""Vide les instances de backend et le cache de chargement mlx."""
get_llm_backend.cache_clear()
try:
from .mlx_backend import _load
_load.cache_clear()
except Exception: # noqa: BLE001
pass

View File

@@ -0,0 +1,171 @@
"""Backend LLM via LM Studio (API OpenAI locale).
LM Studio sert indifferemment des modeles GGUF *et* MLX charges depuis sa GUI,
exposes sur un endpoint OpenAI-compatible (`http://127.0.0.1:1234/v1` par
defaut). InkFlow ne fait que parler HTTP : zero dependance native a compiler, et
le modele reste charge entre redemarrages d'InkFlow.
Caveat : `enable_thinking=False` (coupe la pensee des modeles hybrides cote mlx)
n'est pas pilotable de facon fiable via l'API ; le template embarque decide. En
mode non-raisonnement on prend le `content` final et on le strip de toute facon.
"""
from __future__ import annotations
import os
from typing import Callable, Optional
from .base import LLMBackend
from ._text import _has_complete_json, _strip_reasoning
from ...settings import get_settings
def list_models(base_url: str) -> list[dict]:
"""Liste les modeles LLM *telecharges* dans LM Studio (charges ou non).
Utilise l'API REST native (`/api/v0/models`) et non `/v1/models` (qui ne
renvoie que les modeles deja charges) : on peut ainsi proposer n'importe quel
modele telecharge ; LM Studio le charge a la volee (JIT) a la 1re requete.
Renvoie [{id, state, type}] filtre sur les LLM/VLM. Leve en cas d'echec.
"""
import httpx
root = base_url.rstrip("/")
if root.endswith("/v1"):
root = root[:-len("/v1")]
resp = httpx.get(f"{root}/api/v0/models", timeout=5.0)
resp.raise_for_status()
data = resp.json().get("data", [])
return [
{"id": m.get("id"), "state": m.get("state"), "type": m.get("type")}
for m in data if m.get("type") in ("llm", "vlm")
]
class LMStudioBackend(LLMBackend):
"""Moteur LM Studio : client OpenAI pointe sur le serveur local."""
name = "lmstudio"
def __init__(self, model_ref: str):
super().__init__(model_ref)
self._client = None
self._model = None # resolu paresseusement (model_ref vide -> modele actif)
def _ensure_client(self):
if self._client is None:
try:
from openai import OpenAI
except ImportError as exc: # noqa: BLE001
raise RuntimeError(
"Le paquet `openai` est requis pour le backend LM Studio "
"(pip install -e backend)."
) from exc
base_url = get_settings().lmstudio_base_url
# api_key factice : LM Studio n'authentifie pas, mais le SDK l'exige.
self._client = OpenAI(base_url=base_url, api_key="lm-studio")
return self._client
def _resolve_model(self, client) -> str:
"""Renvoie le nom de modele a utiliser (model_ref, ou 1er modele charge)."""
if self.model_ref:
return self.model_ref
if self._model is None:
try:
models = client.models.list()
except Exception as exc: # noqa: BLE001
raise self._connection_error(exc) from exc
ids = [m.id for m in getattr(models, "data", [])]
if not ids:
raise RuntimeError(
"Aucun modele charge dans LM Studio : charge un modele "
"(GGUF ou MLX) dans l'app avant de lancer l'analyse."
)
self._model = ids[0]
return self._model
def _connection_error(self, exc: Exception) -> RuntimeError:
url = get_settings().lmstudio_base_url
return RuntimeError(
f"LM Studio injoignable sur {url} — lance l'application et active le "
f"serveur local (onglet Developer > Start Server). Detail: {exc}"
)
def _sampling(self, max_tokens: int, temperature: float) -> dict:
"""Params de sampling a transmettre a LM Studio.
Par defaut (`lmstudio_defer_config=True`) : dict VIDE -> on delegue
temperature ET plafond de tokens a la config du modele charge dans LM
Studio (ne pas imposer `max_tokens` evite de tronquer la reponse, ce qui
cassait les modeles a raisonnement). Le contexte est de toute facon gere
au chargement cote LM Studio. Si l'utilisateur desactive la delegation,
on reimpose les reglages "Generation Gemma" d'InkFlow.
"""
if get_settings().lmstudio_defer_config:
return {}
return {"temperature": temperature, "max_tokens": max_tokens}
def complete(
self,
messages: list[dict],
*,
max_tokens: int,
temperature: float,
reasoning: bool,
token_sink: Optional[Callable[[str], None]] = None,
) -> str:
client = self._ensure_client()
model = self._resolve_model(client)
sampling = self._sampling(max_tokens, temperature)
# Prefill optionnel de la reponse assistant (INKFLOW_LMSTUDIO_PREFILL) :
# ex. "<think></think>" force les modeles distilles a raisonnement (Qwen)
# a sauter la pensee (seul levier efficace quand enable_thinking/_no_think
# sont ignores). Le modele continue a partir du prefill -> JSON direct.
prefill = os.environ.get("INKFLOW_LMSTUDIO_PREFILL")
if prefill:
messages = messages + [{"role": "assistant", "content": prefill}]
from openai import APIConnectionError
# LM Studio separe la pensee (`reasoning_content`) de la reponse finale
# (`content`, deja propre). On ne renvoie QUE `content` : la facade en
# extrait le JSON. La pensee n'est diffusee qu'au `token_sink` (affichage
# --stream) ; l'inclure dans le retour risquerait de capter un JSON
# d'exemple present dans le raisonnement. Pour les modeles qui mettent au
# contraire la pensee INLINE dans `content` (<think>...), la facade la
# retire via _strip_reasoning quand reasoning=True.
if token_sink is not None or reasoning:
content_parts: list[str] = []
try:
stream = client.chat.completions.create(
model=model, messages=messages, stream=True, **sampling,
)
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
rc = getattr(delta, "reasoning_content", None)
if rc and token_sink is not None:
token_sink(rc) # pensee : affichage seulement
piece = delta.content or ""
if piece:
content_parts.append(piece)
if token_sink is not None:
token_sink(piece)
# Arret anticipe en mode raisonnement : des que la reponse
# finale (content) contient un JSON complet, inutile de
# continuer (certains modeles divaguent ensuite).
if reasoning and piece and ("}" in piece or "]" in piece):
if _has_complete_json(_strip_reasoning("".join(content_parts))):
break
except APIConnectionError as exc:
raise self._connection_error(exc) from exc
if token_sink is not None:
token_sink("\n") # separe les generations successives
return "".join(content_parts)
try:
resp = client.chat.completions.create(
model=model, messages=messages, **sampling,
)
except APIConnectionError as exc:
raise self._connection_error(exc) from exc
return resp.choices[0].message.content or ""

View File

@@ -0,0 +1,127 @@
"""Backend LLM mlx-lm (Apple Silicon) — moteur historique d'InkFlow.
Charge un modele mlx-community paresseusement (une fois par process, cache LRU)
et genere via le template de chat du tokenizer. Comportement strictement
identique a l'ancien `Gemma.generate` : controle fin de `enable_thinking`,
streaming avec arret anticipe des que le JSON post-pensee est complet.
"""
from __future__ import annotations
import json
from functools import lru_cache
from typing import Callable, Optional
from .base import LLMBackend
from ._text import _REASONING_END_MARKERS, _has_complete_json, _strip_reasoning
@lru_cache(maxsize=2)
def _load(model_id: str):
# Import paresseux : evite de charger mlx tant qu'on n'analyse pas.
from mlx_lm import load
return load(model_id)
def _resolve_chat_template(model_id: str, tokenizer) -> Optional[str]:
"""Renvoie un template de chat a passer explicitement, ou None.
Certaines conversions (Mistral recents...) logent leur template dans un
fichier `chat_template.jinja` que le downloader de mlx-lm n'embarque pas
toujours : `tokenizer.chat_template` est alors vide et `apply_chat_template`
echoue. On recupere alors le fichier officiel du repo. None si le tokenizer
possede deja un template (cas courant) ou si aucun n'est disponible.
"""
if getattr(tokenizer, "chat_template", None):
return None
from pathlib import Path
from huggingface_hub import hf_hub_download
# Selon les conversions : fichier Jinja brut, ou JSON {"chat_template": ...}.
for fname in ("chat_template.jinja", "chat_template.json"):
try:
text = Path(hf_hub_download(model_id, fname)).read_text(encoding="utf-8")
except Exception: # noqa: BLE001 — fichier absent, on tente le suivant
continue
if fname.endswith(".json"):
data = json.loads(text)
return data.get("chat_template") if isinstance(data, dict) else None
return text
return None # aucun template dispo -> apply_chat_template levera une erreur claire
class MLXBackend(LLMBackend):
"""Moteur mlx-lm : modeles mlx-community (HuggingFace) sur Apple Silicon."""
name = "mlx"
def __init__(self, model_ref: str):
super().__init__(model_ref)
self._model = None
self._tokenizer = None
self._chat_template = None # template recupere si absent du tokenizer
def _ensure_loaded(self) -> None:
if self._model is None:
self._model, self._tokenizer = _load(self.model_ref)
self._chat_template = _resolve_chat_template(
self.model_ref, self._tokenizer)
def complete(
self,
messages: list[dict],
*,
max_tokens: int,
temperature: float,
reasoning: bool,
token_sink: Optional[Callable[[str], None]] = None,
) -> str:
self._ensure_loaded()
from mlx_lm.sample_utils import make_sampler
# Modeles hybrides (Qwen3...) : hors mode raisonnement, on DESACTIVE la
# pensee via enable_thinking=False -> JSON direct, bien plus rapide. Avec
# raisonnement, on laisse penser puis la facade retire la pensee. Ce
# kwarg est ignore par les templates qui ne l'utilisent pas (Gemma...).
template_kwargs = {}
if not reasoning:
template_kwargs["enable_thinking"] = False
formatted = self._tokenizer.apply_chat_template(
messages, add_generation_prompt=True, tokenize=False,
chat_template=self._chat_template, # None -> celui du tokenizer
**template_kwargs,
)
sampler = make_sampler(temp=temperature)
# On streame (token par token) si : un sink est branche (--stream) OU on
# est en mode raisonnement (pour pouvoir s'arreter des que la reponse est
# prete, sans subir les boucles de pensee sans fin). Sinon, lot rapide.
if token_sink is not None or reasoning:
from mlx_lm import stream_generate
parts = []
seen_end = False # marqueur de fin de pensee rencontre
for resp in stream_generate(
self._model, self._tokenizer, prompt=formatted,
max_tokens=max_tokens, sampler=sampler,
):
parts.append(resp.text)
if token_sink is not None:
token_sink(resp.text)
# Arret anticipe : une fois la pensee close, des que le JSON
# post-pensee est complet, inutile de continuer a generer.
if reasoning and ("}" in resp.text or "]" in resp.text):
buf = "".join(parts)
if not seen_end:
seen_end = any(mk in buf for mk in _REASONING_END_MARKERS)
if seen_end and _has_complete_json(_strip_reasoning(buf)):
break
if token_sink is not None:
token_sink("\n") # separe les generations successives
return "".join(parts)
from mlx_lm import generate
return generate(
self._model,
self._tokenizer,
prompt=formatted,
max_tokens=max_tokens,
sampler=sampler,
verbose=False,
)