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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)} />;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user