/* Entrada de audio: el micrófono sólo se abre al tocar Grabar voz. */ const $ = (id) => document.getElementById(id); let take = null, sourceUrl = null, recorder = null, stream = null, chunks = [], started = 0, timer = null, busy = false; const status = (message) => { $('status').textContent = message; }; function options() { return {pitch: +$('pitch').value / 100, texture: +$('texture').value / 100, echo: +$('echo').value / 100}; } function refreshControls() { const recording = recorder?.state === 'recording'; $('record').disabled = busy || recording; $('stop').disabled = !recording; $('file').disabled = busy || recording; $('process').disabled = busy || recording || !take; $('synthesize').disabled = busy || recording; } function selectTake(blob, name) { if (sourceUrl) URL.revokeObjectURL(sourceUrl); take = blob; sourceUrl = URL.createObjectURL(blob); $('source').src = sourceUrl; $('source-name').textContent = name; $('source-area').hidden = false; refreshControls(); status('Toma lista. Podés escucharla antes de transformarla.'); } function stopRecording() { if (recorder?.state === 'recording') recorder.stop(); clearInterval(timer); timer = null; } $('record').addEventListener('click', async () => { try { if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) throw new Error('Este navegador no permite grabar. Podés importar un audio.'); stream = await navigator.mediaDevices.getUserMedia({audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}}); const mime = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4'].find((m) => MediaRecorder.isTypeSupported(m)); recorder = new MediaRecorder(stream, mime ? {mimeType: mime} : undefined); chunks = []; recorder.addEventListener('dataavailable', (e) => { if (e.data.size) chunks.push(e.data); }); recorder.addEventListener('stop', () => { const blob = new Blob(chunks, {type: recorder.mimeType}); stream.getTracks().forEach((track) => track.stop()); stream = null; selectTake(blob, 'Grabación de micrófono'); }, {once: true}); recorder.start(1000); started = Date.now(); $('timer').textContent = '00:00'; timer = setInterval(() => { const sec = Math.floor((Date.now() - started) / 1000); $('timer').textContent = `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`; if (sec >= 179) stopRecording(); }, 250); refreshControls(); status('Grabando… hablá con tu interpretación de la criatura.'); } catch (error) { stream?.getTracks().forEach((track) => track.stop()); stream = null; status(error.name === 'NotAllowedError' ? 'Permití el micrófono o importá un audio.' : error.message); refreshControls(); } }); $('stop').addEventListener('click', stopRecording); $('file').addEventListener('change', () => { const file = $('file').files[0]; if (!file) return; if (file.size > 32 * 1024 * 1024) { status('Usá un archivo de hasta 32 MB.'); return; } selectTake(file, file.name); }); /* Ajustes acotados: el botón restaura la misma mezcla que usa la criatura. */ for (const id of ['pitch', 'texture', 'echo']) $(id).addEventListener('input', () => { $(`${id}-label`).textContent = `${$(id).value}%`; }); $('reset').addEventListener('click', () => { for (const [id, value] of Object.entries({pitch:77, texture:100, echo:100})) { $(id).value = value; $(`${id}-label`).textContent = `${value}%`; } status('Preset criatura restaurado.'); }); $('drone').addEventListener('click', () => { for (const [id, value] of Object.entries({pitch:65, texture:45, echo:150})) { $(id).value = value; $(`${id}-label`).textContent = `${value}%`; } status('Tarareo grave listo. Grabá una nota larga y dejá que se apague despacio.'); }); /* Transformación y exportación: la preescucha nunca comienza por sí sola. */ async function renderAudio(endpoint, body, headers) { busy = true; refreshControls(); status('Preparando la voz…'); try { const response = await fetch(endpoint, {method:'POST', headers, body}); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'No se pudo preparar el audio.'); $('processed').src = data.files.wav; $('processed').load(); for (const ext of ['wav', 'ogg', 'mp3']) $(`save-${ext}`).href = data.files[ext] + '?download'; $('result').hidden = false; $('empty-result').hidden = true; status(`Toma lista: ${data.seconds.toFixed(1)} segundos. Escuchala y guardá tu versión.`); } catch (error) { status(error.message); } finally { busy = false; refreshControls(); } } $('process').addEventListener('click', () => renderAudio('/api/process', take, {'Content-Type':'application/octet-stream', 'X-Voice-Settings':JSON.stringify(options())})); $('synthesize').addEventListener('click', () => { const text = $('text').value.trim(); if (!text) { status('Escribí una frase primero.'); return; } renderAudio('/api/synthesize', JSON.stringify({text, settings:options()}), {'Content-Type':'application/json'}); }); $('close').addEventListener('click', async () => { if (busy || recorder?.state === 'recording') { status('Terminá la toma antes de cerrar.'); return; } try { await fetch('/api/shutdown', {method:'POST'}); status('Estudio cerrado. Ya podés cerrar esta pestaña.'); document.querySelectorAll('button, input, textarea').forEach((el) => el.disabled = true); } catch { status('El estudio ya está cerrado.'); } }); window.addEventListener('beforeunload', () => { stream?.getTracks().forEach((track) => track.stop()); }); fetch('/api/health').then((r) => r.json()).then((state) => { $('synthesis-card').hidden = !state.synthesis; }).catch(() => status('Volvé a abrir el estudio desde su carpeta.'));