mi-asistente / index.html
Moises711's picture
Update index.html
5fb5ee9 verified
Raw History Blame Contribute Delete
10.6 kB
<!DOCTYPE html>
<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)!important}.err{color:#dc2626!important}
#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>