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

@@ -18,6 +18,7 @@ function VoiceSelect({ voices, value, onChange }) {
export default function CastEditor({ slug, busy }) {
const [cast, setCast] = useState(null);
const [voices, setVoices] = useState([]);
const [unresolved, setUnresolved] = useState([]);
const [saved, setSaved] = useState(false);
const [playing, setPlaying] = useState(null);
const [msg, setMsg] = useState(null);
@@ -25,12 +26,17 @@ export default function CastEditor({ slug, busy }) {
const reload = () =>
api.getCast(slug).then((d) => { setCast(d.cast); setVoices(d.voicebank.entries); });
// Locuteurs apparus dans l'analyse mais rattachés à aucun personnage.
const reloadUnresolved = () =>
api.getUnresolvedSpeakers(slug)
.then((d) => setUnresolved(d.unresolved || [])).catch(() => {});
useEffect(() => { reload(); }, [slug]);
useEffect(() => { reload(); reloadUnresolved(); }, [slug]);
// Recharge le casting quand un job de fond (dédup / casting chapitre) se termine.
useEffect(() => {
if (busy) return;
reload().then(() => {
reloadUnresolved();
if (dedupPending.current) {
dedupPending.current = false;
api.getCast(slug).then((d) =>
@@ -58,6 +64,20 @@ export default function CastEditor({ slug, busy }) {
const update = (patch) => { setCast({ ...cast, ...patch }); setSaved(false); };
const setChar = (name, voiceId) =>
update({ characters: cast.characters.map((c) => c.name === name ? { ...c, voice_id: voiceId } : c) });
const setAlias = (name, aliasesStr) =>
update({ characters: cast.characters.map((c) => c.name === name
? { ...c, aliases: aliasesStr.split(",").map((s) => s.trim()).filter(Boolean) } : c) });
// Rattache une surface non résolue à un personnage (alias) et enregistre.
const attachToChar = async (surface, name) => {
if (!name) return;
const characters = cast.characters.map((c) => c.name === name
? { ...c, aliases: [...(c.aliases || []), surface] } : c);
const next = { ...cast, characters };
setCast(next); setSaved(true);
await api.putCast(slug, next);
reloadUnresolved();
};
const preview = async (voiceId) => {
if (!voiceId) return;
@@ -70,7 +90,7 @@ export default function CastEditor({ slug, busy }) {
} catch { setPlaying(null); }
};
const save = async () => { await api.putCast(slug, cast); setSaved(true); };
const save = async () => { await api.putCast(slug, cast); setSaved(true); reloadUnresolved(); };
return (
<div className="space-y-4">
@@ -98,9 +118,9 @@ export default function CastEditor({ slug, busy }) {
<div key={c.name} className="flex items-center gap-3 px-4 py-2.5">
<div className="flex-1 min-w-0">
<p className="truncate font-serif text-sm">{c.name}</p>
{c.aliases?.length > 0 && (
<p className="truncate text-xs text-ink-muted">alias : {c.aliases.join(", ")}</p>
)}
<input className="input mt-1 w-full text-xs" placeholder="alias (séparés par des virgules)"
value={(c.aliases || []).join(", ")}
onChange={(e) => setAlias(c.name, e.target.value)} />
{c.description && <p className="truncate text-xs text-ink-muted">{c.description}</p>}
</div>
<span className="chip bg-ink-edge text-ink-muted">
@@ -114,6 +134,32 @@ export default function CastEditor({ slug, busy }) {
</div>
))}
</div>
{unresolved.length > 0 && (
<div className="card p-4 space-y-2">
<p className="font-serif text-sm">
Locuteurs non rattachés <span className="text-ink-muted">({unresolved.length})</span>
</p>
<p className="text-xs text-ink-muted">
Ces noms apparaissent dans l'analyse mais ne correspondent à aucun personnage
(ils seraient lus par la voix du narrateur). Rattachez-les comme alias.
</p>
{unresolved.map((u) => (
<div key={u.speaker} className="flex items-center gap-3 text-sm">
<span className="flex-1 min-w-0 truncate">
{u.speaker} <span className="text-ink-muted">×{u.count}</span>
</span>
<select className="input" defaultValue=""
onChange={(e) => attachToChar(u.speaker, e.target.value)}>
<option value=""> rattacher à </option>
{cast.characters.map((c) => (
<option key={c.name} value={c.name}>{c.name}</option>
))}
</select>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,17 @@ import { Spinner } from "./ui.jsx";
// Description declarative des champs, groupes par section.
const SECTIONS = [
{
title: "Moteur LLM (analyse)",
hint: "Choisit le moteur d'analyse de texte. MLX charge un modèle mlx-community en local ; LM Studio délègue à son serveur OpenAI local (onglet Developer > Start Server), qui sert des modèles GGUF et MLX chargés dans son interface.",
fields: [
{ key: "gemma_backend", label: "Backend", type: "select",
options: [["mlx", "MLX (mlx-lm, Apple Silicon)"], ["lmstudio", "LM Studio (API locale — GGUF + MLX)"]] },
{ key: "lmstudio_base_url", label: "LM Studio — URL du serveur", type: "text" },
{ key: "lmstudio_model", label: "LM Studio — modèle à charger", type: "lmstudio_model" },
{ key: "lmstudio_defer_config", label: "Déléguer la config de génération à LM Studio (température, tokens, contexte gérés côté LM Studio)", type: "checkbox" },
],
},
{
title: "Modèles (identifiants MLX / HuggingFace)",
hint: "Changer un identifiant recharge un autre modèle (peut déclencher un téléchargement au prochain usage).",
@@ -15,7 +26,7 @@ const SECTIONS = [
},
{
title: "Génération Gemma",
hint: "Paramètres d'échantillonnage de l'analyse (locuteurs, personnages, prononciations).",
hint: "Paramètres d'échantillonnage de l'analyse (locuteurs, personnages, prononciations). S'appliquent au backend MLX ; pour LM Studio, la config du modèle dans LM Studio prime (sauf si la délégation est décochée ci-dessus).",
fields: [
{ key: "gemma_temperature", label: "Température", type: "number", step: 0.05, min: 0, max: 2 },
{ key: "gemma_max_tokens", label: "Max tokens", type: "number", step: 1, min: 64, max: 8192 },
@@ -60,8 +71,50 @@ const SECTIONS = [
},
];
// Selecteur de modele LM Studio : liste les modeles TELECHARGES (via l'API REST
// native), avec saisie libre (datalist). On peut donc choisir un modele non
// encore charge : LM Studio le charge a la volee (JIT) a la 1re requete.
function LmStudioModelField({ value, onChange }) {
const [models, setModels] = useState(null);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true); setErr(null);
try { const r = await api.listLmStudioModels(); setModels(r.models || []); }
catch (e) { setErr(String(e)); setModels(null); }
finally { setLoading(false); }
};
return (
<div className="space-y-1">
<div className="flex gap-2">
<input className="input w-full" type="text" list="lmstudio-models"
placeholder="(vide = modèle actuellement chargé)"
value={value ?? ""} onChange={(e) => onChange(e.target.value)} />
<datalist id="lmstudio-models">
{(models || []).map((m) => (
<option key={m.id} value={m.id}>{m.state}</option>
))}
</datalist>
<button type="button" className="btn-ghost whitespace-nowrap"
onClick={load} disabled={loading}>
{loading ? "…" : "Lister"}
</button>
</div>
{err && <p className="text-xs text-red-400">
LM Studio injoignable lance l'app et active le serveur local.</p>}
{models && <p className="text-xs text-ink-muted">
{models.length} modèle(s) téléchargé(s). Un modèle non chargé sera chargé
automatiquement (JIT) à la première analyse.</p>}
</div>
);
}
function Field({ field, value, onChange }) {
const common = "input w-full";
if (field.type === "lmstudio_model")
return <LmStudioModelField value={value} onChange={onChange} />;
if (field.type === "checkbox")
return <input type="checkbox" className="h-4 w-4"
checked={!!value} onChange={(e) => onChange(e.target.checked)} />;

View File

@@ -34,10 +34,12 @@ export const api = {
j(`/api/books/${slug}/render`, json("POST", { chapters, backend, mono })),
getCast: (slug) => j(`/api/books/${slug}/cast`),
putCast: (slug, cast) => j(`/api/books/${slug}/cast`, json("PUT", cast)),
getUnresolvedSpeakers: (slug) => j(`/api/books/${slug}/cast/unresolved`),
getPron: (slug) => j(`/api/books/${slug}/pronunciation`),
putPron: (slug, pron) => j(`/api/books/${slug}/pronunciation`, json("PUT", pron)),
getSettings: () => j("/api/settings"),
putSettings: (settings) => j("/api/settings", json("PUT", settings)),
listLmStudioModels: () => j("/api/lmstudio/models"),
audioUrl: (slug, idx) => `/api/books/${slug}/audio/${idx}`,
coverUrl: (slug) => `/api/books/${slug}/cover`,
previewVoice: async (voiceId, text) => {