Spaces:
Running
Running
Download app.js from Elsimplerd/simplestudio-voz: direct link, hf CLI and curl.
- Browser
- Download file 9.59 kB
-
https://huggingface.co/spaces/Elsimplerd/simplestudio-voz/resolve/main/app.js
- Command line
-
hf download hf://spaces/Elsimplerd/simplestudio-voz/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Elsimplerd/simplestudio-voz/resolve/main/app.js
9.59 kB
| // Simple Studio Voz · página. Elige GPU o procesador, decodifica el audio y habla con motor.js. | |
| ; | |
| // 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(); }); | |