/* ClonarVozTab — v2 */ // ── Helpers outside component ───────────────────────────────────────────── function CvStepBadge({ n, done, active }) { return (
{done ? : n}
); } function CvMic({ size = 18, color = "currentColor" }) { return ( ); } function CvStop({ size = 24 }) { return ( ); } const CV_PROMPTS = [ "El zumbido apagado de la ciudad parecía distinto esta noche — un poco más suave, un poco más paciente, como si también hubiera decidido bajar el ritmo y escuchar.", "La primera luz de la mañana entraba por las persianas dibujando franjas doradas sobre el suelo. Todo estaba quieto, casi como si el mundo esperara una señal.", "Cuando el tren se detuvo, nadie bajó. Las puertas se abrieron y volvieron a cerrarse en silencio, llevándose consigo el eco de pasos que quizás nunca existieron.", ]; const CV_LANGS = { Spanish: "Español", English: "English", Chinese: "中文", French: "Français", German: "Deutsch", Japanese: "日本語", Korean: "한국어", }; const CV_GUIDE = [ { label: "Sube tu audio", sub: "MP3 / WAV, 3-30s limpia" }, { label: "Detalles", sub: "Nombre, género, idioma" }, { label: "Entrenamiento", sub: "≈ 60s" }, { label: "Lista para usar", sub: null }, ]; function fmtTime(s) { return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`; } // ── Main component ──────────────────────────────────────────────────────── function ClonarVozTab() { const [origin, setOrigin] = React.useState("upload"); // Recording const [recState, setRecState] = React.useState("idle"); // idle | recording | done const [timer, setTimer] = React.useState(0); const [recordedBlob, setRecordedBlob] = React.useState(null); const [bars, setBars] = React.useState(new Array(48).fill(0.06)); const [promptIdx, setPromptIdx] = React.useState(0); // Upload const [uploadedFile, setUploadedFile] = React.useState(null); const [dragOver, setDragOver] = React.useState(false); // Details const [voiceName, setVoiceName] = React.useState(""); const [gender, setGender] = React.useState("male"); const [language, setLanguage] = React.useState("Spanish"); const [noiseReduction, setNoiseReduction] = React.useState(true); // Submit const [cloning, setCloning] = React.useState(false); const [cloneError, setCloneError] = React.useState(null); const [cloneSuccess, setCloneSuccess] = React.useState(false); // Refs const mediaRecRef = React.useRef(null); const streamRef = React.useRef(null); const audioCtxRef = React.useRef(null); const analyserRef = React.useRef(null); const rafRef = React.useRef(null); const timerRef = React.useRef(null); const fileInputRef = React.useRef(null); React.useEffect(() => () => cleanup(), []); function cleanup() { if (rafRef.current) cancelAnimationFrame(rafRef.current); if (timerRef.current) clearInterval(timerRef.current); if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop()); if (audioCtxRef.current && audioCtxRef.current.state !== "closed") audioCtxRef.current.close(); } async function startRecording() { setCloneError(null); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; const ctx = new (window.AudioContext || window.webkitAudioContext)(); audioCtxRef.current = ctx; const src = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = 128; src.connect(analyser); analyserRef.current = analyser; const mr = new MediaRecorder(stream); mediaRecRef.current = mr; const chunks = []; mr.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); }; mr.onstop = () => { setRecordedBlob(new Blob(chunks, { type: "audio/webm" })); setRecState("done"); setBars(new Array(48).fill(0.06)); cancelAnimationFrame(rafRef.current); }; mr.start(100); setRecState("recording"); setTimer(0); setRecordedBlob(null); function drawLoop() { const data = new Uint8Array(analyserRef.current.frequencyBinCount); analyserRef.current.getByteTimeDomainData(data); const samples = []; const step = Math.floor(data.length / 48); for (let i = 0; i < 48; i++) { const v = Math.abs(data[Math.min(i * step, data.length - 1)] - 128) / 128; samples.push(Math.max(0.06, v)); } setBars(samples); rafRef.current = requestAnimationFrame(drawLoop); } drawLoop(); timerRef.current = setInterval(() => { setTimer(prev => { if (prev >= 30) { stopRecording(); return 30; } return prev + 1; }); }, 1000); } catch (e) { setCloneError("No se pudo acceder al micrófono: " + e.message); } } function stopRecording() { if (mediaRecRef.current && mediaRecRef.current.state !== "inactive") mediaRecRef.current.stop(); if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop()); if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } } function resetRecording() { stopRecording(); setRecState("idle"); setTimer(0); setRecordedBlob(null); setBars(new Array(48).fill(0.06)); } function switchOrigin(o) { resetRecording(); setUploadedFile(null); setOrigin(o); } function handleFileDrop(e) { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files[0]; if (f) acceptFile(f); } function acceptFile(f) { setCloneError(null); const MAX_BYTES = 10 * 1024 * 1024; if (f.size > MAX_BYTES) { setCloneError(`El archivo pesa ${(f.size / (1024 * 1024)).toFixed(1)}MB — el máximo son 10MB.`); return; } const url = URL.createObjectURL(f); const probe = new Audio(url); probe.onloadedmetadata = () => { const dur = probe.duration; URL.revokeObjectURL(url); if (isFinite(dur) && (dur < 3 || dur > 30)) { setCloneError(`El audio dura ${dur.toFixed(1)}s — tiene que durar entre 3 y 30 segundos.`); return; } setUploadedFile(f); }; probe.onerror = () => { URL.revokeObjectURL(url); setUploadedFile(f); // no se pudo medir la duración en el navegador; se deja pasar y que lo valide el servidor }; } async function handleClone() { if (!canClone || cloning) return; setCloning(true); setCloneError(null); setCloneSuccess(false); const fd = new FormData(); const file = recordedBlob ? new File([recordedBlob], "grabacion.webm", { type: recordedBlob.type }) : uploadedFile; fd.append("file", file); fd.append("voice_name", voiceName.trim()); fd.append("language_tag", language); fd.append("gender_tag", gender); fd.append("need_noise_reduction", noiseReduction ? "true" : "false"); try { const r = await fetch("/api/voice/clones", { method: "POST", credentials: "same-origin", body: fd }); const d = await r.json(); if (!r.ok) { setCloneError(d.detail || "Error al clonar"); return; } setCloneSuccess(true); resetRecording(); setUploadedFile(null); setVoiceName(""); } catch (e) { setCloneError(e.message || "Error de red"); } finally { setCloning(false); } } const hasAudio = !!(recordedBlob || uploadedFile); const canClone = hasAudio && voiceName.trim().length > 0; const guideStep = cloneSuccess ? 4 : hasAudio && voiceName.trim() ? 3 : hasAudio ? 2 : 1; const recIsRecording = recState === "recording"; const recIsDone = recState === "done"; return (
{/* ── Left: wizard steps ── */}
{/* ── Step 2a: Grabación (deshabilitado — solo se permite subir audio) ── */} {origin === "record" && (
Grabación
Lee el prompt en un sitio silencioso, sin reverberación.
{/* Big button */}
{recState === "idle" ? "Toca para empezar" : recIsRecording ? "Grabando… toca para detener" : "Grabación completa"}
{/* Timer */}
{fmtTime(timer)} / 0:30
{/* Progress bar */}
{/* Waveform */}
{bars.map((h, i) => (
))}
{recIsDone && ( )}
{/* Prompt card */} {!recIsDone && (
Lee el prompt
{CV_PROMPTS[promptIdx]}
Prompt {promptIdx + 1} / {CV_PROMPTS.length}
)}
)} {/* ── Step 1: Subir audio ── */} {origin === "upload" && (
Subir audio
MP3 / WAV · 3s a 30s · máx. 10MB · sin música de fondo.
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={handleFileDrop} onClick={() => fileInputRef.current && fileInputRef.current.click()} style={{ border: `2px dashed ${dragOver ? "var(--accent)" : "var(--border)"}`, borderRadius: "var(--radius)", padding: "40px 20px", textAlign: "center", cursor: "pointer", background: dragOver ? "var(--accent-softer)" : "transparent", transition: "all 0.2s", }}> { const f = e.target.files[0]; if (f) acceptFile(f); }}/> {uploadedFile ? (
{uploadedFile.name}
{(uploadedFile.size / (1024 * 1024)).toFixed(2)} MB · Haz clic para cambiar
) : (
Arrastra tu audio aquí
o haz clic para seleccionar · MP3, WAV · 3-30s · Máx. 10MB
)}
)} {/* ── Step 2: Detalles ── */} {hasAudio && (
0} active={!voiceName.trim()}/>
Detalles
Nombre, género, idioma y ajustes.
setVoiceName(e.target.value)} autoFocus/>
{/* ── Right sidebar ── */}
{/* Cómo funciona */}
Cómo funciona 5 pasos
{CV_GUIDE.map((s, i) => { const n = i + 1; const done = guideStep > n; const active = guideStep === n; return (
{done ? : n}
{s.label}
{s.sub &&
{s.sub}
}
); })}
{/* Resumen */}
Resumen
{[ { label: "Endpoint", value: "/v1m/voice/clone", mono: true }, { label: "Nombre", value: voiceName.trim() || "—" }, { label: "Género", value: gender }, { label: "Idioma", value: CV_LANGS[language] || language }, { label: "Tags", value: "2 tags" }, { label: "Noise red.", value: noiseReduction ? "true" : "false" }, ].map(row => (
{row.label} {row.value}
))} {/* Cost */}
Coste 1
{/* Legal */}
Al clonar confirmas que eres titular de los derechos sobre esta voz o que tienes consentimiento explícito.
); } Object.assign(window, { ClonarVozTab });