Elsimplerd's picture
Upload 7 files
c87d678 verified
Raw History Blame Contribute Delete
9.59 kB
// Simple Studio Voz · página. Elige GPU o procesador, decodifica el audio y habla con motor.js.
'use strict';
// Dónde están los archivos pesados (modelo 143 MB y motor wasm 22 MB). Se sirven desde Hugging Face
// (gratis y sin tope de transferencia) para no agotar el plan gratuito del alojamiento de la página.
// Cada versión del modelo vive en su propia carpeta (modelo/<hash>/), así que esos archivos no cambian.
// Solo en 127.0.0.1 se puede cambiar con ?pesado=URL para probar.
const PESADO_PUBLICO = 'https://huggingface.co/Elsimplerd/simplestudio-voz/resolve/main/';
const PESADO = (location.hostname === '127.0.0.1' && new URLSearchParams(location.search).get('pesado')) || PESADO_PUBLICO;
const CACHE_MODELO = 'simplestudio-modelo-4b0880716f8c';
const MOVIL = (navigator.userAgentData && navigator.userAgentData.mobile) || /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const MAX_MIN = MOVIL ? 3 : 10;
const MAX_BYTES = 1024 * 1024 * 1024;
const $ = (id) => document.getElementById(id);
const ui = {
zona: $('zona'), archivo: $('archivo'), panel: $('panel'), estado: $('estado'), detalle: $('detalle'),
barra: $('barra'), relleno: $('relleno'), confirmar: $('confirmar'), resultado: $('resultado'),
antes: $('antes'), despues: $('despues'), descargar: $('descargar'),
};
ui.zona.querySelector('#limite').textContent = `WAV, MP3, M4A, OGG, FLAC o un video · hasta ${MAX_MIN} minutos`;
let worker = null, listo = false, motor = null, trabajo = null, urls = [];
function ls(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) { return null; } }
function mostrar(estado, detalle, pct, esError) {
ui.panel.classList.remove('oculto');
ui.panel.classList.toggle('error', !!esError);
ui.estado.textContent = estado;
ui.detalle.textContent = detalle || '';
ui.barra.classList.toggle('oculto', pct == null);
if (pct != null) ui.relleno.style.width = Math.max(0, Math.min(100, pct)) + '%';
}
const ocupado = (b) => { ui.archivo.disabled = b; ui.zona.classList.toggle('oculto', b); };
async function elegirMotor() {
const hilos = self.crossOriginIsolated ? Math.max(1, Math.min(4, navigator.hardwareConcurrency || 1)) : 1;
if (ls('ssv-sin-gpu') !== '1' && navigator.gpu) {
try { if (await navigator.gpu.requestAdapter()) return { ep: 'webgpu', hilos }; } catch (e) { /* sin GPU */ }
}
return { ep: 'wasm', hilos };
}
function nuevoWorker() {
if (worker) worker.terminate();
listo = false;
worker = new Worker('motor.js', { type: 'module' });
worker.onmessage = (ev) => manejar(ev.data);
worker.onerror = (ev) => manejar({ type: 'error', fase: 'cargar', mensaje: ev.message || 'error del motor' });
worker.postMessage({ type: 'cargar', pesado: PESADO, ep: motor.ep, hilos: motor.hilos });
}
function pasarACpu(motivo) {
console.warn('Simple Studio Voz: se usa el procesador.', motivo);
ls('ssv-sin-gpu', '1');
motor = { ep: 'wasm', hilos: motor.hilos };
nuevoWorker();
}
function manejar(m) {
if (m.type === 'descarga') {
const mb = (m.n / 1e6).toFixed(0), tot = (m.total / 1e6).toFixed(0);
mostrar(m.desdeCache ? 'Preparando el motor…' : 'Descargando el motor (solo la primera vez)…',
m.desdeCache ? '' : `${mb} de ${tot} MB`, 100 * m.n / m.total);
} else if (m.type === 'modelo') {
listo = true;
mostrar('Preparando…', '', 0);
if (trabajo) procesar();
} else if (m.type === 'progreso') {
const t = (performance.now() - trabajo.t0) / 1000, falta = t / m.k * (m.nv - m.k);
mostrar('Mejorando la voz…', m.k < 2 ? 'Calculando el tiempo restante…' : `Faltan unos ${formatoTiempo(falta)}`, 100 * m.k / m.nv);
} else if (m.type === 'listo') terminado(m);
else if (m.type === 'error') {
const red = m.mensaje.startsWith('red:');
if (motor.ep === 'webgpu' && !red) { pasarACpu(m.mensaje); return; } // la GPU falló: reintenta con el procesador
console.error('Simple Studio Voz:', m.mensaje);
const memoria = /memory|memoria|allocation|out of range|bad_alloc/i.test(m.mensaje);
mostrar('No se pudo mejorar este audio.',
memoria ? 'El dispositivo se quedó sin memoria. Prueba con un audio más corto o en un computador.'
: red || m.fase === 'cargar' ? 'No se pudo descargar el motor. Revisa tu conexión y vuelve a intentarlo.'
: 'Ocurrió un error al procesarlo. Vuelve a intentarlo o prueba con otro archivo.', null, true);
trabajo = null; ocupado(false);
if (m.fase === 'cargar' || memoria) { worker.terminate(); worker = null; listo = false; }
}
}
function formatoTiempo(s) {
s = Math.max(1, Math.round(s));
if (s < 60) return s + ' s';
const m = Math.floor(s / 60), r = s % 60;
return r ? `${m} min ${r} s` : `${m} min`;
}
async function decodificar(file) {
const buf = await file.arrayBuffer();
const Ctx = self.OfflineAudioContext || self.webkitOfflineAudioContext;
const ctx = new Ctx(1, 1, 48000);
const ab = await new Promise((ok, mal) => { const p = ctx.decodeAudioData(buf, ok, mal); if (p && p.then) p.then(ok, mal); });
const x = new Float32Array(ab.length);
for (let c = 0; c < ab.numberOfChannels; c++) {
const d = ab.getChannelData(c);
for (let i = 0; i < x.length; i++) x[i] += d[i] / ab.numberOfChannels;
}
return x;
}
async function modeloEnCache() {
try { return (await caches.keys()).includes(CACHE_MODELO); } catch (e) { return false; }
}
function datosLimitados() {
const c = navigator.connection;
return !!(c && (c.saveData || c.type === 'cellular')); // effectiveType no es fiable al cargar la página
}
async function empezar(file) {
if (!file) return;
ui.resultado.classList.add('oculto');
ui.confirmar.classList.add('oculto');
if (file.size > MAX_BYTES) { mostrar('El archivo es demasiado grande.', 'Usa un archivo de menos de 1 GB.', null, true); return; }
ocupado(true);
mostrar('Leyendo el archivo…', file.name, null);
let x;
try { x = await decodificar(file); }
catch (e) { ocupado(false); mostrar('No se pudo leer este archivo.', 'Prueba con WAV, MP3 o M4A.', null, true); return; }
const dur = x.length / 48000;
if (dur > MAX_MIN * 60 + 1) {
ocupado(false);
mostrar(`El audio dura ${formatoTiempo(dur)}.`, `El límite es de ${MAX_MIN} minutos${MOVIL ? ' en móviles' : ''}. Recórtalo y vuelve a intentarlo.`, null, true);
return;
}
if (dur < 0.5) { ocupado(false); mostrar('El audio es demasiado corto.', '', null, true); return; }
trabajo = { file, x, dur, t0: 0 };
if (!listo && !worker && !(await modeloEnCache()) && datosLimitados()) {
mostrar('Hay que descargar el motor: 165 MB, una sola vez.', 'Parece que usas datos móviles. ¿Descargar ahora?', null);
ui.confirmar.classList.remove('oculto');
return;
}
arrancar();
}
async function arrancar() {
ui.confirmar.classList.add('oculto');
if (listo) { procesar(); return; }
if (!worker) { motor = motor || await elegirMotor(); nuevoWorker(); }
mostrar('Preparando el motor…', '', 0);
}
function procesar() {
trabajo.t0 = performance.now();
mostrar('Mejorando la voz…', 'Calculando el tiempo restante…', 0);
const x = trabajo.x.slice(); // se conserva el original por si hay que reintentar con el procesador
worker.postMessage({ type: 'procesar', audio: x }, [x.buffer]);
}
function wav24(x) {
const n = x.length, b = new ArrayBuffer(44 + n * 3), v = new DataView(b);
const s = (o, t) => { for (let i = 0; i < t.length; i++) v.setUint8(o + i, t.charCodeAt(i)); };
s(0, 'RIFF'); v.setUint32(4, 36 + n * 3, true); s(8, 'WAVEfmt '); v.setUint32(16, 16, true);
v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, 48000, true); v.setUint32(28, 144000, true);
v.setUint16(32, 3, true); v.setUint16(34, 24, true); s(36, 'data'); v.setUint32(40, n * 3, true);
for (let i = 0, o = 44; i < n; i++, o += 3) {
let q = Math.round(Math.max(-1, Math.min(1, x[i])) * 8388607); if (q < 0) q += 16777216;
v.setUint8(o, q & 255); v.setUint8(o + 1, (q >> 8) & 255); v.setUint8(o + 2, (q >> 16) & 255);
}
return new Blob([b], { type: 'audio/wav' });
}
function terminado(m) {
const seg = (performance.now() - trabajo.t0) / 1000;
urls.forEach(URL.revokeObjectURL);
urls = [URL.createObjectURL(trabajo.file), URL.createObjectURL(wav24(m.audio))];
ui.antes.src = urls[0]; ui.despues.src = urls[1];
ui.descargar.href = urls[1];
ui.descargar.download = trabajo.file.name.replace(/\.[^.]*$/, '') + '-mejorado.wav';
mostrar('Listo.', `${formatoTiempo(trabajo.dur)} de audio en ${formatoTiempo(seg)}.`, 100);
ui.resultado.classList.remove('oculto');
self.__ultimo = { dur: trabajo.dur, seg, ep: m.ep, hilos: motor.hilos };
trabajo = null;
}
ui.archivo.addEventListener('change', (e) => { empezar(e.target.files[0]); e.target.value = ''; });
['dragenter', 'dragover'].forEach((t) => ui.zona.addEventListener(t, (e) => { e.preventDefault(); ui.zona.classList.add('encima'); }));
['dragleave', 'drop'].forEach((t) => ui.zona.addEventListener(t, () => ui.zona.classList.remove('encima')));
ui.zona.addEventListener('drop', (e) => { e.preventDefault(); empezar(e.dataTransfer.files[0]); });
$('siDescargar').addEventListener('click', arrancar);
$('noDescargar').addEventListener('click', () => { trabajo = null; ocupado(false); ui.panel.classList.add('oculto'); });
$('otro').addEventListener('click', () => { ui.resultado.classList.add('oculto'); ui.panel.classList.add('oculto'); ocupado(false); ui.archivo.click(); });