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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

40
frontend/dist/assets/index-qJQqFSeO.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InkFlow — EPUB → Livre audio</title>
<script type="module" crossorigin src="/assets/index-CMUl6Yfl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DlPmWkkU.css">
<script type="module" crossorigin src="/assets/index-qJQqFSeO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CO-QVT_t.css">
</head>
<body>
<div id="root"></div>

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) => {