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
75 lines
2.7 KiB
Python
75 lines
2.7 KiB
Python
"""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")
|