Volver al índice
docs/voice-studio/app.js
/* 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.'));