/* GenVozTab — v6 */
// ── VoicePicker modal ─────────────────────────────────────────────────────
const _VP_PALETTES = [
["#7c5cff","#3a1fd1"],["#f87171","#b91c1c"],["#60a5fa","#1d4ed8"],
["#4ade80","#15803d"],["#fbbf24","#b45309"],["#a78bfa","#6d28d9"],
["#fb923c","#c2410c"],["#34d399","#065f46"],["#f472b6","#9d174d"],
];
const _VP_LANG = {
spanish:"ES", español:"ES", english:"EN", inglés:"EN",
portuguese:"PT", português:"PT", french:"FR", français:"FR",
german:"DE", deutsch:"DE", japanese:"JP", japonés:"JP",
chinese:"ZH", chino:"ZH", korean:"KR", coreano:"KR", italian:"IT", italiano:"IT",
};
function vpGrad(name) {
let h = 0;
for (let i = 0; i < (name || "").length; i++) h = (h * 31 + (name || "").charCodeAt(i)) & 0x7fffffff;
const [c1, c2] = _VP_PALETTES[h % _VP_PALETTES.length];
return `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`;
}
function vpLangs(tags) {
return [...new Set((tags || []).map(t => _VP_LANG[(t || "").toLowerCase().trim()]).filter(Boolean))];
}
function VoicePicker({ onSelect, onClose }) {
const [tab, setTab] = React.useState("biblioteca");
const [presetVoices, setPresetVoices] = React.useState([]);
const [cloneVoices, setCloneVoices] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [search, setSearch] = React.useState("");
const [playingId, setPlayingId] = React.useState(null);
const audioRef = React.useRef(null);
React.useEffect(() => {
(async () => {
setLoading(true);
try {
const [pr, cr] = await Promise.all([
fetch("/api/public-voices", { credentials: "same-origin" }).then(r => r.json()),
fetch("/api/voice/clones", { credentials: "same-origin" }).then(r => r.json()).catch(() => ({})),
]);
setPresetVoices(pr.voices || []);
const raw = cr.voice_list || cr.data || cr.voices || cr.clones || [];
setCloneVoices(raw.map(c => ({
id: c.voice_id || c.id,
voice_id: c.voice_id || c.id,
voice_name: c.voice_name || c.name || "Clon sin nombre",
description: [c.gender, c.language].filter(Boolean).join(" · "),
cover_url: null,
sample_url: c.demo_audio || c.sample_url || null,
tags: [c.language, c.gender].filter(Boolean),
is_clone: true,
})));
} catch (e) { console.error(e); }
finally { setLoading(false); }
})();
}, []);
function playPreview(url, id) {
if (audioRef.current) audioRef.current.pause();
if (playingId === id) { setPlayingId(null); return; }
const a = new Audio(url);
audioRef.current = a;
a.play().catch(() => {});
setPlayingId(id);
a.onended = () => setPlayingId(null);
}
function handleSelect(v) {
if (audioRef.current) audioRef.current.pause();
onSelect(v);
}
const rawList = tab === "biblioteca" ? presetVoices : cloneVoices;
const displayList = search.trim()
? rawList.filter(v => {
const q = search.toLowerCase();
return (v.voice_name || "").toLowerCase().includes(q) ||
(v.description || "").toLowerCase().includes(q) ||
(v.tags || []).some(t => (t || "").toLowerCase().includes(q));
})
: rawList;
return (
{ if (e.target === e.currentTarget) onClose(); }}>
{/* ── Header ── */}
{/* ── Body ── */}
{loading && (
)}
{!loading && displayList.length === 0 && (
{tab === "clones" ? "No tienes voces clonadas aún" : "No se encontraron voces"}
)}
{!loading && displayList.length > 0 && (
{displayList.map(v => {
const vid = v.voice_id || v.id;
const langs = vpLangs(v.tags);
const bg = vpGrad(v.voice_name);
const playing = playingId === vid;
return (
handleSelect(v)}
style={{ overflow: "hidden", cursor: "pointer", display: "flex", flexDirection: "column", transition: "transform 0.12s, box-shadow 0.12s" }}
onMouseEnter={e => { e.currentTarget.style.transform = "translateY(-2px)"; e.currentTarget.style.boxShadow = "0 8px 28px rgba(0,0,0,0.3)"; }}
onMouseLeave={e => { e.currentTarget.style.transform = ""; e.currentTarget.style.boxShadow = ""; }}>
{/* Cover */}
{v.cover_url
?

:
}
{v.sample_url && (
)}
{v.is_clone && (
TÚ
)}
{/* Body */}
{v.voice_name}
{langs.map(l => (
{l}
))}
{v.description && (
{v.description}
)}
);
})}
)}
);
}
// ── Toggle helper ─────────────────────────────────────────────────────────
function GvToggle({ value, onChange }) {
return (
);
}
// ── Section label ─────────────────────────────────────────────────────────
function GvLabel({ children }) {
return (
{children}
);
}
function GvDivider() {
return ;
}
// ── GenVozTab ─────────────────────────────────────────────────────────────
function GenVozTab({ onNavigate }) {
const [text, setText] = React.useState("");
const [engine, setEngine] = React.useState("minimax");
const [voiceId, setVoiceId] = React.useState("");
const [voiceName, setVoiceName] = React.useState("");
const [voiceCover, setVoiceCover] = React.useState(null);
const [model, setModel] = React.useState("speech-2.8-hd");
const [speed, setSpeed] = React.useState(0.95);
const [pitch, setPitch] = React.useState(0);
const [vol, setVol] = React.useState(1.0);
const [langBoost, setLangBoost] = React.useState("Spanish");
const [loudness, setLoudness] = React.useState(false);
const [exportTranscript, setExportTranscript] = React.useState(true);
const [pickerOpen, setPickerOpen] = React.useState(false);
const [generating, setGenerating] = React.useState(false);
const [audioUrl, setAudioUrl] = React.useState(null);
const [error, setError] = React.useState(null);
const pollRef = React.useRef(null);
const MAX_CHARS = 1455849;
const MODELS = {
minimax: [
{ value: "speech-2.8-hd", label: "speech-2.8-hd — Alta calidad" },
{ value: "speech-2.6-hd", label: "speech-2.6-hd — Equilibrado" },
{ value: "speech-01-turbo", label: "speech-01-turbo — Rápido" },
],
elevenlabs: [
{ value: "eleven_multilingual_v2", label: "Multilingual v2 — Mejor calidad" },
{ value: "eleven_turbo_v2_5", label: "Turbo v2.5 — Rápido" },
{ value: "eleven_flash_v2_5", label: "Flash v2.5 — Más rápido" },
],
};
const LANGS = ["Auto", "Spanish", "English", "Chinese", "French", "German", "Japanese", "Korean", "Portuguese", "Italian"];
React.useEffect(() => {
setModel(engine === "minimax" ? "speech-2.8-hd" : "eleven_multilingual_v2");
setVoiceId(""); setVoiceName(""); setVoiceCover(null);
setAudioUrl(null); setError(null);
}, [engine]);
function handleVoiceSelect(v) {
setVoiceId(v.voice_id || v.id || "");
setVoiceName(v.voice_name || "");
setVoiceCover(v.cover_url || v.avatar_url || null);
setPickerOpen(false);
}
function stopPolling() {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
}
React.useEffect(() => () => stopPolling(), []);
function handleReset() {
setSpeed(0.95); setPitch(0); setVol(1.0);
setLoudness(false); setExportTranscript(true);
}
async function handleGenerate() {
if (!text.trim() || !voiceId || generating) return;
setGenerating(true); setError(null); setAudioUrl(null); stopPolling();
try {
const r = await fetch("/api/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ text: text.trim(), voice_id: voiceId, voice_name: voiceName, model, speed, pitch: Math.round(pitch), vol, language_boost: langBoost, engine }),
});
const d = await r.json();
if (!r.ok) { setError(d.detail || "Error al iniciar generación"); setGenerating(false); return; }
const tid = d.task_id || d.id;
if (!tid) { setError("No se recibió task_id"); setGenerating(false); return; }
let tries = 0;
pollRef.current = setInterval(async () => {
tries++;
if (tries > 60) { stopPolling(); setGenerating(false); setError("Tiempo de espera agotado"); return; }
try {
const pr = await fetch(`/api/voice/task/${tid}`, { credentials: "same-origin" });
const pd = await pr.json();
const done = ["done", "Success", "success", "completed"].includes(pd.status);
const failed = ["error", "failed"].includes(pd.status);
if (done) {
stopPolling(); setGenerating(false);
const url = (pd.metadata && pd.metadata.audio_url) || pd.audio_url || (pd.data && pd.data.audio_url) || null;
if (url) setAudioUrl(url); else setError("Audio generado pero URL no encontrada");
} else if (failed) {
stopPolling(); setGenerating(false);
setError(pd.error_message || pd.message || "Error en la generación");
}
} catch (_) {}
}, 3000);
} catch (e) {
setError(e.message || "Error de red"); setGenerating(false);
}
}
function handleDrop(e) {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => setText(ev.target.result.slice(0, MAX_CHARS));
reader.readAsText(file);
}
const canGenerate = text.trim().length > 0 && voiceId && !generating;
const currentModels = MODELS[engine] || MODELS.minimax;
// topbar ≈ 64px, page padding top 28 + bottom 48 = 76px → 140px total
const panelH = "calc(100vh - 140px)";
const cardBox = {
background: "var(--bg-card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-lg)",
boxShadow: "var(--shadow-card)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
};
return (
{/* ── RIGHT: Controls box ── */}
{/* Header */}
Generar Voz
{/* Scrollable body */}
{/* Engine */}
Motor TTS
{/* Voice */}
Voz
{/* Language */}
Idioma
{/* Model */}
Modelo
{/* Sliders */}
Parámetros
{[
{ label: "Velocidad", value: speed, display: speed.toFixed(2), min: 0.5, max: 2, step: 0.05, set: setSpeed, marks: ["Lenta", "Rápida"] },
{ label: "Tono", value: pitch, display: pitch >= 0 ? `+${pitch}` : String(pitch), min: -12, max: 12, step: 1, set: v => setPitch(parseInt(v)), marks: null },
{ label: "Volumen", value: vol, display: vol.toFixed(2), min: 0.1, max: 2, step: 0.05, set: setVol, marks: null },
].map(s => (
{s.label}
{s.display}
s.set(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--accent)", display: "block", margin: 0 }}/>
{s.marks && (
{s.marks.map(m => {m})}
)}
))}
{/* Toggles */}
Opciones
Normalización de loudness
Beta
Exportar transcripción
+15%
{/* Warning */}
La velocidad puede ser lenta con texto corto y rápida con texto largo.
{/* ── LEFT: Textarea box ── */}
{pickerOpen &&
setPickerOpen(false)}/>}
);
}
Object.assign(window, { GenVozTab });