Spaces:
Sleeping
Sleeping
Download index.html from Moises711/mi-asistente: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/spaces/Moises711/mi-asistente/resolve/main/index.html
- Command line
-
hf download hf://spaces/Moises711/mi-asistente/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Moises711/mi-asistente/resolve/main/index.html
10.6 kB
| <html lang="es"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Grabador de Dataset de Voz</title> | |
| <style> | |
| :root{--primary:#4f46e5;--primary-hover:#4338ca;--danger:#ef4444;--danger-hover:#dc2626; | |
| --bg:#f8fafc;--card:#fff;--text:#0f172a;--muted:#64748b;--border:#e2e8f0;--ok:#16a34a} | |
| body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text); | |
| max-width:480px;margin:30px auto;padding:20px} | |
| .card{background:var(--card);border-radius:16px;padding:26px;box-shadow:0 4px 6px -1px rgb(0 0 0/.1)} | |
| h2{margin:0 0 20px;text-align:center} | |
| label{display:block;margin:14px 0 6px;font-size:.85rem;font-weight:600;color:var(--muted)} | |
| input,textarea{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:8px; | |
| font-size:.95rem;box-sizing:border-box;font-family:inherit} | |
| textarea{height:110px;resize:vertical} | |
| .row{display:flex;gap:10px}.row>div{flex:1} | |
| #current{font-size:2.4rem;font-weight:700;text-align:center;margin:22px 0 4px;word-break:break-word} | |
| #info{text-align:center;font-size:.8rem;color:var(--muted)} | |
| button{width:100%;padding:14px;margin-top:16px;background:var(--primary);color:#fff;border:none; | |
| border-radius:10px;font-size:1rem;font-weight:600;cursor:pointer} | |
| button:hover:not(:disabled){background:var(--primary-hover)} | |
| button.recording{background:var(--danger)} | |
| button:disabled{opacity:.6;cursor:not-allowed} | |
| button.sec{background:#e2e8f0;color:var(--text);margin-top:8px;padding:9px;font-size:.85rem} | |
| #pbc{height:6px;background:var(--border);border-radius:3px;margin-top:14px;overflow:hidden;display:none} | |
| #pb{width:0;height:100%;background:var(--danger)} | |
| #status{margin-top:14px;text-align:center;font-size:.9rem;font-weight:500;min-height:22px;color:var(--muted)} | |
| .ok{color:var(--ok)}.err{color:#dc2626} | |
| #chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:14px} | |
| .chip{font-size:.75rem;padding:4px 8px;border-radius:999px;background:#eef2ff;cursor:pointer;border:1px solid transparent} | |
| .chip.done{background:#dcfce7} | |
| .chip.cur{border-color:var(--primary);font-weight:700} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <h2>🎙️ Recolector de Voz</h2> | |
| <label for="dataset_name">Dataset (carpeta):</label> | |
| <input id="dataset_name" value="entrenamiento_v1"> | |
| <div class="row"> | |
| <div> | |
| <label for="max_repetitions">Repeticiones por palabra:</label> | |
| <input type="number" id="max_repetitions" value="4" min="1"> | |
| </div> | |
| <div> | |
| <label for="duration">Segundos por grabación:</label> | |
| <input type="number" id="duration" value="3" min="1" max="30" step="0.5"> | |
| </div> | |
| </div> | |
| <label for="wordsText">Lista de palabras (una por línea):</label> | |
| <textarea id="wordsText"></textarea> | |
| <div id="current">—</div> | |
| <div id="info"></div> | |
| <button id="recordBtn">Grabar</button> | |
| <div class="row"> | |
| <button class="sec" id="prevBtn">← Anterior</button> | |
| <button class="sec" id="nextBtn">Saltar →</button> | |
| </div> | |
| <div id="pbc"><div id="pb"></div></div> | |
| <p id="status">Lista para grabar</p> | |
| <div id="chips"></div> | |
| </div> | |
| <script> | |
| const DEFAULT_WORDS = ['hola']; | |
| const $ = id => document.getElementById(id); | |
| const wordsText = $('wordsText'), datasetEl = $('dataset_name'), maxEl = $('max_repetitions'), | |
| durEl = $('duration'), recordBtn = $('recordBtn'), statusEl = $('status'), | |
| pbc = $('pbc'), pb = $('pb'); | |
| let words = [], idx = 0, counts = {}, recording = false; | |
| // ---------- Utilidades ---------- | |
| // Misma normalización que el servidor (slugify en app.py) | |
| function slug(t){ | |
| const s = t.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[^\x00-\x7F]/g,'') | |
| .replace(/[^a-zA-Z0-9]+/g,'_').replace(/^_+|_+$/g,'').toLowerCase(); | |
| return s || 'palabra'; | |
| } | |
| const cnt = w => counts[slug(w)] || 0; | |
| function setStatus(t, type=''){ statusEl.textContent = t; statusEl.className = type; } | |
| const maxRep = () => Math.max(1, parseInt(maxEl.value) || 4); | |
| function parseWords(){ | |
| const list = wordsText.value.split(/[\n,]+/).map(w => w.trim()).filter(Boolean); | |
| return [...new Set(list)]; | |
| } | |
| function saveLocal(){ | |
| try{ localStorage.setItem('vr_words', wordsText.value); | |
| localStorage.setItem('vr_dataset', datasetEl.value); | |
| localStorage.setItem('vr_max', maxEl.value); }catch(e){} | |
| } | |
| function loadLocal(){ | |
| try{ | |
| wordsText.value = localStorage.getItem('vr_words') || DEFAULT_WORDS.join('\n'); | |
| datasetEl.value = localStorage.getItem('vr_dataset') || datasetEl.value; | |
| maxEl.value = localStorage.getItem('vr_max') || maxEl.value; | |
| }catch(e){ wordsText.value = DEFAULT_WORDS.join('\n'); } | |
| } | |
| // ---------- Estado / UI ---------- | |
| function firstPending(from=0){ | |
| for(let i=0;i<words.length;i++){ | |
| const j=(from+i)%words.length; | |
| if(cnt(words[j]) < maxRep()) return j; | |
| } | |
| return -1; | |
| } | |
| function render(){ | |
| const m = maxRep(); | |
| const w = words[idx]; | |
| if(!words.length){ $('current').textContent='—'; $('info').textContent='Agrega palabras a la lista'; recordBtn.disabled=true; } | |
| else if(idx === -1){ $('current').textContent='🎉 ¡Completado!'; $('info').textContent='Todas las palabras tienen sus '+m+' repeticiones'; recordBtn.disabled=true; } | |
| else { $('current').textContent=w; | |
| $('info').textContent=`Palabra ${idx+1} de ${words.length} · Rep. ${cnt(w)} / ${m}`; | |
| recordBtn.disabled=false; } | |
| const chips=$('chips'); chips.innerHTML=''; | |
| words.forEach((wd,i)=>{ | |
| const c=document.createElement('span'); | |
| const n=cnt(wd); | |
| c.className='chip'+(n>=m?' done':'')+(i===idx?' cur':''); | |
| c.textContent=`${wd} ${n}/${m}`; | |
| c.onclick=()=>{ if(!recording){ idx=i; render(); } }; | |
| chips.appendChild(c); | |
| }); | |
| } | |
| async function refresh(){ | |
| words = parseWords(); | |
| setStatus('Cargando progreso...'); | |
| try{ | |
| const r = await fetch('/counts/?dataset_name='+encodeURIComponent(datasetEl.value), {cache:'no-store'}); | |
| const out = await r.json(); | |
| counts = out.counts || {}; | |
| if(out.hf_error) setStatus('⚠️ No se pudo leer el dataset de HF: '+out.hf_error,'err'); | |
| else setStatus('Lista para grabar'); | |
| }catch(e){ counts = {}; setStatus('⚠️ No se pudo cargar el progreso: '+e.message,'err'); } | |
| idx = words.length ? firstPending(0) : 0; | |
| render(); | |
| } | |
| function advance(){ | |
| if(!words.length) return; | |
| const n = firstPending(idx+1); | |
| idx = n; render(); | |
| if(n>=0) setStatus(`🔄 Siguiente palabra: "${words[n]}"`); | |
| else setStatus('🎉 ¡Dataset completo!','ok'); | |
| } | |
| // ---------- WAV real (16 kHz mono) ---------- | |
| async function blobToWav(blob){ | |
| const ctx = new (window.AudioContext||window.webkitAudioContext)(); | |
| const audio = await ctx.decodeAudioData(await blob.arrayBuffer()); | |
| ctx.close(); | |
| const rate = 16000; | |
| const off = new OfflineAudioContext(1, Math.max(1,Math.ceil(audio.duration*rate)), rate); | |
| const src = off.createBufferSource(); src.buffer = audio; src.connect(off.destination); src.start(); | |
| const data = (await off.startRendering()).getChannelData(0); | |
| const buf = new ArrayBuffer(44 + data.length*2), v = new DataView(buf); | |
| const wr=(o,s)=>{for(let i=0;i<s.length;i++) v.setUint8(o+i,s.charCodeAt(i));}; | |
| wr(0,'RIFF'); v.setUint32(4,36+data.length*2,true); wr(8,'WAVE'); wr(12,'fmt '); | |
| v.setUint32(16,16,true); v.setUint16(20,1,true); v.setUint16(22,1,true); | |
| v.setUint32(24,rate,true); v.setUint32(28,rate*2,true); v.setUint16(32,2,true); v.setUint16(34,16,true); | |
| wr(36,'data'); v.setUint32(40,data.length*2,true); | |
| for(let i=0,o=44;i<data.length;i++,o+=2){ | |
| const s=Math.max(-1,Math.min(1,data[i])); v.setInt16(o, s<0?s*0x8000:s*0x7FFF, true); | |
| } | |
| return new Blob([v],{type:'audio/wav'}); | |
| } | |
| // ---------- Grabación ---------- | |
| recordBtn.addEventListener('click', async () => { | |
| if(recording || idx<0 || !words.length) return; | |
| const word = words[idx]; | |
| let stream; | |
| try{ stream = await navigator.mediaDevices.getUserMedia({audio:true}); } | |
| catch(e){ return setStatus('❌ No se pudo acceder al micrófono: '+e.message,'err'); } | |
| const rec = new MediaRecorder(stream), chunks = []; | |
| const durMs = Math.max(1000, (parseFloat(durEl.value)||3)*1000); | |
| recording = true; | |
| rec.ondataavailable = e => { if(e.data.size>0) chunks.push(e.data); }; | |
| rec.onstop = async () => { | |
| stream.getTracks().forEach(t=>t.stop()); | |
| clearInterval(timer); pbc.style.display='none'; pb.style.width='0%'; | |
| recordBtn.classList.remove('recording'); | |
| setStatus('⏳ Procesando y subiendo...'); recordBtn.disabled = true; | |
| try{ | |
| const wav = await blobToWav(new Blob(chunks)); | |
| const fd = new FormData(); | |
| fd.append('file', wav, 'audio.wav'); | |
| fd.append('word', word); | |
| fd.append('max_repetitions', maxRep()); | |
| fd.append('dataset_name', datasetEl.value); | |
| const res = await fetch('/upload_audio/', {method:'POST', body:fd}); | |
| const out = await res.json(); | |
| if(res.ok){ | |
| counts[slug(word)] = out.repetition; | |
| if(out.hf_error) setStatus(`⚠️ Guardado local (${out.repetition}/${out.max_repetitions}) pero NO subió a HF: ${out.hf_error}`,'err'); | |
| else setStatus(`✅ Guardado: Rep. ${out.repetition} / ${out.max_repetitions}`,'ok'); | |
| render(); | |
| if(out.repetition >= maxRep()) setTimeout(advance, 1200); | |
| } else { | |
| let msg = out.detail || 'Error en el servidor'; | |
| if(typeof msg !== 'string') msg = JSON.stringify(msg); | |
| setStatus('❌ '+msg,'err'); | |
| if(msg.includes('límite máximo')){ counts[slug(word)]=maxRep(); render(); setTimeout(advance,1200); } | |
| } | |
| }catch(e){ setStatus('❌ Error: '+e.message,'err'); } | |
| finally{ recording=false; recordBtn.textContent='Grabar'; recordBtn.disabled = idx<0; } | |
| }; | |
| rec.start(); | |
| recordBtn.classList.add('recording'); recordBtn.textContent='🔴 Grabando...'; | |
| setStatus('Habla ahora...'); pbc.style.display='block'; | |
| const t0 = Date.now(); | |
| const timer = setInterval(()=>{ pb.style.width = Math.min((Date.now()-t0)/durMs*100,100)+'%'; }, 50); | |
| setTimeout(()=>{ if(rec.state==='recording') rec.stop(); }, durMs); | |
| }); | |
| // ---------- Eventos ---------- | |
| wordsText.addEventListener('change', ()=>{ saveLocal(); refresh(); }); | |
| datasetEl.addEventListener('change', ()=>{ saveLocal(); refresh(); }); | |
| maxEl.addEventListener('change', ()=>{ saveLocal(); render(); }); | |
| $('nextBtn').onclick = ()=>{ if(!recording && words.length){ idx=(idx+1)%words.length; render(); } }; | |
| $('prevBtn').onclick = ()=>{ if(!recording && words.length){ idx=(idx-1+words.length)%words.length; render(); } }; | |
| loadLocal(); refresh(); | |
| </script> | |
| </body> | |
| </html> |