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.'));