Files
InkFlow/backend/inkflow/settings.py
colgora ba1813c583 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
2026-06-21 21:32:31 +02:00

201 lines
9.0 KiB
Python

"""Reglages techniques editables au runtime (globaux a l'app).
Contrairement a `config.py` (constantes figees lues a l'import, surchargeables
seulement par variables d'environnement au demarrage), ce module expose un objet
`Settings` *persiste* dans `data/settings.json` et modifiable depuis l'UI.
Les valeurs par defaut reprennent celles de `config.py`. Le code du pipeline
consulte `get_settings()` au moment de l'execution ; une sauvegarde invalide les
caches de modeles (backends TTS, chargement Gemma) pour que les nouveaux
identifiants/parametres prennent effet sans redemarrage.
"""
from __future__ import annotations
import threading
from typing import Optional
from pydantic import BaseModel, Field
from . import config
# --- Prompts systeme par defaut (source canonique) ---------------------------
# Ces chaines pilotent les trois taches Gemma. L'utilisateur peut les editer.
DEFAULT_PROMPT_SPEAKERS = (
"Tu es un assistant d'analyse litteraire. Tu identifies QUI prononce chaque "
"replique de dialogue dans un extrait de roman en francais. Une liste des "
"personnages du chapitre t'est fournie : choisis le locuteur dans cette "
"liste en recopiant son nom EXACTEMENT. Appuie-toi sur la narration qui "
"PRECEDE et qui SUIT chaque replique (incise d'attribution type 'dit "
"Marie'), sur les vocatifs (le personnage a qui l'on s'adresse) et sur "
"l'alternance des tours de parole. Mets 'inconnu' si tu n'es pas sur. Tu "
"reponds UNIQUEMENT en JSON valide, sans texte autour."
)
DEFAULT_PROMPT_SPEAKERS_REFINE = (
"Tu es un assistant d'analyse litteraire. On te donne des repliques dont le "
"locuteur est reste indetermine, avec le locuteur DEJA identifie des "
"repliques voisines. Deduis qui parle en exploitant l'alternance des tours "
"de parole et le contexte narratif autour. Choisis le nom dans la liste des "
"personnages fournie, en le recopiant exactement, ou 'inconnu' si vraiment "
"indeterminable. Tu reponds UNIQUEMENT en JSON valide, sans texte autour."
)
DEFAULT_PROMPT_CHARACTERS = (
"Tu es un assistant d'analyse litteraire. Tu extrais la liste des "
"personnages d'un extrait de roman et leurs attributs vocaux. Tu reponds "
"UNIQUEMENT en JSON valide."
)
DEFAULT_PROMPT_PRONUNCIATION = (
"Tu es un assistant de preparation de livre audio en francais. Tu reperes "
"les mots dont la prononciation par un synthetiseur vocal francais risque "
"d'etre incorrecte (noms propres etrangers, termes de science-fiction, "
"acronymes). Tu reponds UNIQUEMENT en JSON valide."
)
DEFAULT_PROMPT_INCISES = (
"Tu es un assistant d'analyse litteraire. Tu reperes les INCISES de "
"narration inserees dans une replique de dialogue (ex: 'dit Mamie', "
"'repondit le capitaine'). Tu reponds UNIQUEMENT en JSON valide, sans "
"texte autour."
)
DEFAULT_PROMPT_DEDUP = (
"Tu es un assistant d'analyse litteraire. Tu rapproches les differentes "
"facons de nommer un meme personnage (nom complet, prenom, surnom, "
"diminutif) pour eviter les doublons dans le casting d'un livre audio. Tu "
"ne fusionnes deux noms que si c'est, avec certitude, la meme personne. Tu "
"reponds UNIQUEMENT en JSON valide, sans texte autour."
)
class Settings(BaseModel):
"""Reglages techniques globaux, persistes dans data/settings.json."""
# --- Moteur LLM d'analyse ---
# "mlx" : mlx-lm (Apple Silicon), utilise `gemma_model`.
# "lmstudio" : API OpenAI locale de LM Studio (sert GGUF *et* MLX), utilise
# `lmstudio_base_url` + `lmstudio_model`.
gemma_backend: str = config.GEMMA_BACKEND
lmstudio_base_url: str = config.LMSTUDIO_BASE_URL
lmstudio_model: str = "" # vide -> 1er modele charge dans LM Studio
# Par defaut, le backend LM Studio DELEGUE la config de generation
# (temperature, plafond de tokens) au modele charge dans LM Studio : on
# n'impose ni `temperature` ni `max_tokens` dans la requete. Les reglages
# "Generation Gemma" ci-dessous pilotent alors uniquement le backend MLX.
# Mettre a False pour reimposer ces reglages a LM Studio (utile pour des
# benchmarks reproductibles a temperature fixe).
lmstudio_defer_config: bool = True
# --- Modeles MLX (identifiants HuggingFace) ---
gemma_model: str = config.GEMMA_MODEL
qwen3_model: str = config.QWEN3_TTS_MODEL
kokoro_model: str = config.KOKORO_MODEL
# --- Generation Gemma ---
gemma_temperature: float = Field(0.1, ge=0.0, le=2.0)
gemma_max_tokens: int = Field(2048, ge=64, le=16384)
# Modeles a raisonnement (Gemma 4, DeepSeek-R1, Qwen-think...) : ils emettent
# une chaine de pensee avant la reponse. Active le retrait de cette pensee
# (canaux <|channel>thought.../<channel|>, balises <think>...</think>) AVANT
# le parsing JSON, et releve le plafond de tokens (la pensee en consomme).
gemma_reasoning: bool = False
# Plafond de tokens en mode raisonnement (la pensee en consomme beaucoup).
# La generation s'arrete de toute facon des que la reponse JSON post-pensee
# est complete ; ce plafond est un garde-fou contre les boucles de pensee
# sans fin (certains modeles tournent en rond a temperature 0).
gemma_reasoning_max_tokens: int = Field(4096, ge=256, le=16384)
# Temperature en mode raisonnement. Le decodage GLOUTON (temp 0) fait boucler
# les modeles a raisonnement (repetitions sans fin) ; Qwen & co recommandent
# un echantillonnage. Si la temperature effective est 0, on bascule sur
# celle-ci. Rend le benchmark non deterministe en mode raisonnement (inevitable).
gemma_reasoning_temperature: float = Field(0.6, ge=0.0, le=2.0)
# --- Prompts systeme (analyse) ---
prompt_speakers: str = DEFAULT_PROMPT_SPEAKERS
prompt_speakers_refine: str = DEFAULT_PROMPT_SPEAKERS_REFINE
prompt_characters: str = DEFAULT_PROMPT_CHARACTERS
prompt_pronunciation: str = DEFAULT_PROMPT_PRONUNCIATION
prompt_incises: str = DEFAULT_PROMPT_INCISES # DEPRECIE (detection deterministe)
prompt_dedup: str = DEFAULT_PROMPT_DEDUP
# --- Incises ---
# DEPRECIE : la detection d'incises est desormais deterministe et conscience
# du casting (analysis.segmenter.detect_incises), sans fallback Gemma. Champ
# conserve pour charger les settings.json existants sans erreur.
split_incises_use_gemma: bool = True
# --- Attribution retroactive (2e passe sur les repliques indeterminees) ---
# Apres la 1re passe, une 2e passe ciblee re-resout les repliques restees
# 'inconnu' (ou peu sures) en s'appuyant sur les voisins deja identifies.
# Declenchee seulement s'il reste des doutes -> cout nul sinon.
retro_pass_use_gemma: bool = True
# --- Deduplication du casting ---
# Heuristique (sure, deterministe) par defaut. La passe Gemma rattache en
# plus les variantes non evidentes (diminutifs, titres) mais, avec un petit
# modele local, produit des fusions erronees -> opt-in.
dedup_use_gemma: bool = False
# --- TTS ---
default_backend: str = "kokoro"
language: str = config.DEFAULT_LANGUAGE
kokoro_lang_code: str = config.KOKORO_LANG_CODE
kokoro_default_voice: str = config.KOKORO_DEFAULT_VOICE
qwen3_default_voice: str = config.QWEN3_DEFAULT_VOICE
# --- Audio (encodage final) ---
target_sample_rate: int = Field(config.TARGET_SAMPLE_RATE, ge=8000, le=48000)
mp3_bitrate: str = config.MP3_BITRATE
target_dbfs: float = Field(config.TARGET_DBFS, ge=-40.0, le=0.0)
_LOCK = threading.Lock()
_cache: Optional[Settings] = None
def settings_path():
return config.DATA_DIR / "settings.json"
def get_settings() -> Settings:
"""Renvoie les reglages courants (charges depuis le disque une seule fois)."""
global _cache
with _LOCK:
if _cache is None:
path = settings_path()
if path.exists():
try:
_cache = Settings.model_validate_json(
path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001 — fichier corrompu -> defauts
_cache = Settings()
else:
_cache = Settings()
return _cache
def save_settings(settings: Settings) -> Settings:
"""Persiste les reglages et invalide les caches de modeles."""
global _cache
with _LOCK:
_cache = settings
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(settings.model_dump_json(indent=2), encoding="utf-8")
_invalidate_model_caches()
return settings
def _invalidate_model_caches() -> None:
"""Force le rechargement des modeles apres un changement d'identifiant/param.
`get_backend` est cache par *nom* de backend, pas par id de modele ; sans
purge, un changement d'id serait ignore. Idem pour le chargement Gemma.
"""
try:
from .tts.factory import get_backend
get_backend.cache_clear()
except Exception: # noqa: BLE001
pass
try:
from .analysis.llm.factory import reset_llm_cache
reset_llm_cache()
except Exception: # noqa: BLE001
pass