Pantalla completa ↗
← Código

La web que te obedece por voz

17 oct 2026· nivel fácil· 1 líneaJavaScriptWeb Speech APIvozformularios

Pega una línea en tu web, añade data-voz a tu formulario y ya está: el cliente pulsa el micrófono, dice «mesa para dos mañana a las nueve, a nombre de Laura» y el formulario se rellena solo mientras habla. Funciona en cualquier web y, si la voz no se puede usar, pasa solo a «escribe la frase». Sin librerías y sin pagar a nadie.

PARA TU NEGOCIOUn restaurante, una clínica o una peluquería donde el cliente reserva hablando, como si llamara por teléfono, pero sin que nadie tenga que coger el teléfono. También sirve para buscadores, pedidos o cualquier formulario largo en el móvil.

// CÓMO FUNCIONA

  1. Pega esta línea y añade data-voz a tu formulario: <script src="voz-formulario.js" defer></script> y <form data-voz>. No hay nada más que tocar.
  2. El script busca solo los campos por su nombre, su etiqueta o su tipo (personas, día, hora y nombre) y pone encima su botón de micrófono. Si alguno no lo adivina, le pones data-voz-campo="personas".
  3. Al hablar, interpretar() traduce la frase: pasa «dos» a 2, busca «personas» o «mesa para», «hoy/mañana/el viernes», «a las nueve y media» y «a nombre de…». Son unas pocas expresiones regulares.
  4. Rellena cada campo como lo haría una persona: fechas, horas, números, texto y desplegables (busca la opción que encaja), y avisa a la web con los eventos input y change para que sus validaciones se enteren.
  5. Antes de enseñar el micrófono comprueba que la voz se puede usar: navegador compatible, HTTPS, que la web no bloquee el micrófono y que no lo hayas bloqueado tú. Si algo falla (o falla al hablar: sin micro, sin internet, dictado apagado en el iPhone), lo dice en una línea y pasa solo a «escribe la frase», que rellena el formulario igual. Nunca deja un botón muerto.
  6. El botón vive en un Shadow DOM: tu CSS no lo rompe y él no rompe tu web. Toma el color del texto (sirve en fondo claro u oscuro) o el que le digas con data-voz-color.

// EL CÓDIGO

// Pega esta línea y añade data-voz a tu formulario: <script src="voz-formulario.js" defer></script>. Sube este archivo junto a tu página. Es todo lo que necesitas.

/*  voz-formulario.js · rellena cualquier formulario hablando
 *
 *  1. Pega esta línea en tu web:      <script src="voz-formulario.js" defer></script>
 *  2. Añade data-voz a tu formulario: <form data-voz> … </form>
 *
 *  Y ya está. El script busca solo los campos (personas, día, hora y nombre), pone su botón de micrófono
 *  y, si la voz no se puede usar (Firefox, web sin HTTPS, micrófono bloqueado, iPhone con el dictado
 *  apagado, sin internet…), cambia solo a «escribe la frase», que rellena el formulario igual.
 *
 *  Opcional, en el <form>:  data-voz-color="#c0392b"   color del botón (por defecto, el color del texto)
 *                           data-voz-lang="es-ES"       idioma (por defecto, el lang del <html>)
 *                           data-voz-horario="dia"      «a las cinco» = 17:00 pero «a las diez» = 10:00
 *                                                       (con campo de personas se entiende restaurante: todo por la tarde/noche)
 *  Opcional, en un campo:   data-voz-campo="personas|dia|hora|nombre"   si no lo adivina por su nombre.
 *  Opcional, donde quieras: <div data-voz-aqui></div>   dentro del form, para colocar ahí el botón.
 *
 *  Sin dependencias. AE Lab · aeworks.tech/lab/codigo/web-que-obedece
 */
(function () {
  'use strict';
  if (window.VozFormulario) return;   // si la página lo carga dos veces, no se repite nada

  // ========================== 1. Entender una frase en español ==========================
  // «mesa para dos mañana a las nueve, a nombre de Laura» → {personas: 2, dia: '2026-10-18', hora: '21:00', nombre: 'Laura'}
  const NUM = {un:1,uno:1,una:1,dos:2,tres:3,cuatro:4,cinco:5,seis:6,siete:7,ocho:8,nueve:9,diez:10,once:11,doce:12,
    trece:13,catorce:14,quince:15,dieciseis:16,diecisiete:17,dieciocho:18,diecinueve:19,veinte:20,treinta:30};
  ['uno','dos','tres','cuatro','cinco','seis','siete','ocho','nueve'].forEach((n, i) => NUM['veinti' + n] = 21 + i);
  const DIAS = ['domingo','lunes','martes','miercoles','jueves','viernes','sabado'];
  const MESES = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];
  const NO_NOMBRE = /^(para|el|la|las|a|y|de|con|hoy|mañana|pasado|somos|mesa)$/i;
  const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;

  // horario: 'restaurante' (por defecto: «a las nueve» = 21:00) o 'dia' (clínica, peluquería: «a las diez» = 10:00)
  function interpretar(frase, hoy = new Date(), horario = 'restaurante') {
    const r = {};
    // El nombre primero, con sus mayúsculas: «a nombre de Laura»
    const n = frase.match(/a nombre de\s+((?:[\p{L}'-]+\s*){1,3})/iu);
    if (n) {
      const palabras = [];
      for (const p of n[1].trim().split(/\s+/)) { if (NO_NOMBRE.test(p)) break; palabras.push(p); }
      if (palabras.length) {
        r.nombre = palabras.map((p) => p[0].toUpperCase() + p.slice(1)).join(' ');
        frase = frase.replace(new RegExp('a nombre de\\s+' + palabras.join('\\s+'), 'iu'), '');
      }
    }
    // Minúsculas, sin tildes y los números en palabra pasados a cifra: «dos» → 2
    const t = frase.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
      .replace(/[a-z]+/g, (w) => NUM[w] ?? w);

    // Personas: «4 personas», «mesa para 2», «somos 3»
    const p = t.match(/(\d+) (?:personas?|comensales)/) || t.match(/(?:para|somos) (\d+)(?![:\d])/);
    if (p) r.personas = +p[1];

    // Día: hoy, mañana, pasado mañana, el viernes, el 25 (de octubre)
    const d = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate());
    const sinMadrugada = t.replace(/de la manana/g, '');
    const sem = DIAS.findIndex((x) => new RegExp('\\b' + x + '\\b').test(t));
    const fecha = t.match(/\bel (?:dia )?(\d{1,2})(?: de ([a-z]+))?\b(?! personas)/);
    let hayDia = true;
    if (/pasado manana/.test(t)) d.setDate(d.getDate() + 2);
    else if (/\bmanana\b/.test(sinMadrugada)) d.setDate(d.getDate() + 1);
    else if (sem >= 0) d.setDate(d.getDate() + ((sem - d.getDay() + 7) % 7 || 7));   // el próximo viernes
    else if (fecha) {
      const mes = MESES.indexOf(fecha[2]);
      if (mes >= 0) { d.setMonth(mes, +fecha[1]); if (d < hoy) d.setFullYear(d.getFullYear() + 1); }
      else d.setMonth(d.getMonth() + (+fecha[1] < d.getDate() ? 1 : 0), +fecha[1]);   // si ya pasó, el mes que viene
    }
    else if (!/\bhoy\b|esta noche/.test(t)) hayDia = false;
    if (hayDia) r.dia = iso(d);

    // Hora: «a las 9», «a las 21:30», «9 y media», «8 menos cuarto», «de la noche»
    const h = t.match(/\blas? (\d{1,2})(?:[:.](\d{2}))?(?: y (media|cuarto|\d{1,2}))?(?: menos (cuarto|\d{1,2}))?(?! personas)/)
           || t.match(/\b(\d{1,2}):(\d{2})/) || t.match(/\b(\d{1,2})() y (media|cuarto)/);
    if (h) {
      let hh = +h[1], mm = +(h[2] || 0);
      if (h[3]) mm = h[3] === 'media' ? 30 : h[3] === 'cuarto' ? 15 : +h[3];
      if (h[4]) { hh -= 1; mm = 60 - (h[4] === 'cuarto' ? 15 : +h[4]); }
      // En un restaurante «a las nueve» es por la noche, salvo que digas «de la mañana».
      // En horario de día solo se pasa a la tarde si lo dices o si es antes de las 8 («a las cinco» = 17:00).
      const tarde = /de la (tarde|noche)|del mediodia/.test(t);
      if (hh >= 1 && hh < 12 && !/de la manana/.test(t) && (horario !== 'dia' || tarde || hh < 8)) hh += 12;
      r.hora = `${String(hh % 24).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
    }
    return r;
  }

  // ========================== 2. ¿Se puede usar la voz aquí? ==========================
  const Reconocedor = () => window.SpeechRecognition || window.webkitSpeechRecognition;
  const esApple = () => /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
  // Para ti, el desarrollador: la causa técnica exacta sale en la consola (console.info, no es un error).
  const explicar = (f) => console.info('[voz-formulario] ' + f.tecnico);

  // Cada fallo: lo que lee la persona (una línea), si hay que pasar a escribir y la causa técnica.
  function fallo(codigo) {
    const enMarco = window.self !== window.top;
    const F = {
      'sin-api': ['Tu navegador no entiende la voz: escribe la frase y la rellenamos igual.', true,
        'Este navegador no trae SpeechRecognition (Firefox, por ejemplo). Se usa el modo escrito; no tienes que hacer nada.'],
      'sin-https': ['La voz solo funciona en páginas seguras (https): escribe la frase y la rellenamos igual.', true,
        'La página va por http y los navegadores solo dan el micrófono en páginas seguras (HTTPS o localhost). Activa el certificado SSL de tu hosting.'],
      'politica': ['Esta web tiene el micrófono bloqueado: escribe la frase y la rellenamos igual.', true, enMarco
        ? 'La página está dentro de un <iframe> sin permiso de micrófono: añade allow="microphone" al <iframe> (y que el servidor no envíe Permissions-Policy: microphone=()).'
        : 'Tu servidor envía Permissions-Policy: microphone=(): cámbialo por microphone=(self) (en .htaccess, nginx o el panel de tu hosting).'],
      'denegado': ['Tienes el micrófono bloqueado en esta web: escribe la frase (o actívalo en el candado de la barra de direcciones).', true,
        'navigator.permissions dice que el micrófono está «denied» para este sitio: el usuario lo bloqueó antes y solo él puede reactivarlo.'],
      'not-allowed': ['No tengo permiso para usar el micrófono: escribe la frase y la rellenamos igual.', true,
        'Error not-allowed: el usuario dijo que no al permiso, o lo bloquea Permissions-Policy / un <iframe> sin allow="microphone".'],
      'service-not-allowed': [esApple()
        ? 'Activa el dictado en Ajustes → General → Teclado. Mientras, escribe la frase y la rellenamos igual.'
        : 'Tu navegador no deja usar la voz en esta página: escribe la frase y la rellenamos igual.', true,
        'Error service-not-allowed: el reconocimiento está desactivado en el sistema (en iPhone, con el dictado apagado) o el navegador no lo permite aquí.'],
      'audio-capture': ['No encuentro ningún micrófono: escribe la frase y la rellenamos igual.', true,
        'Error audio-capture: no hay micrófono o lo está usando otra aplicación.'],
      'network': ['Sin conexión no puedo entender la voz: escribe la frase y la rellenamos igual.', true,
        'Error network: Chrome manda el audio a los servidores de Google y no ha llegado (sin internet, un cortafuegos o un navegador como Brave que no lo incluye).'],
      'language-not-supported': ['Tu navegador no entiende la voz en este idioma: escribe la frase y la rellenamos igual.', true,
        'Error language-not-supported: prueba con data-voz-lang="es-ES".'],
      'sin-respuesta': ['La voz no responde en este navegador: escribe la frase y la rellenamos igual.', true,
        'start() no dio señales en 12 s (ni evento start ni error).'],
      'no-speech': ['No te he oído, prueba otra vez.', false, 'Error no-speech: pasaron unos segundos sin voz.'],
      'aborted': ['Se ha cortado. Toca otra vez para hablar.', false, 'Error aborted: otra pestaña o la propia página cortó la escucha.'],
    };
    const [mensaje, escribir, tecnico] = F[codigo]
      || ['La voz ha fallado: escribe la frase y la rellenamos igual.', true, 'Error desconocido del reconocimiento de voz: ' + codigo];
    return {codigo, mensaje, escribir, tecnico};
  }

  // Antes de enseñar el micrófono: ¿hay API, HTTPS, permiso de la web y permiso del usuario? null = todo bien.
  async function comprobar() {
    let f = null;
    if (!Reconocedor()) f = fallo('sin-api');
    else if (window.isSecureContext === false) f = fallo('sin-https');   // localhost cuenta como seguro
    else {
      let permitido;
      try {
        permitido = document.permissionsPolicy?.allowsFeature('microphone') ?? document.featurePolicy?.allowsFeature('microphone');
      } catch (e) { /* navegador sin esa API: no se puede saber, se sigue */ }
      if (permitido === false) f = fallo('politica');
      else {
        try {
          const estado = await navigator.permissions?.query({name: 'microphone'});
          if (estado && estado.state === 'denied') f = fallo('denegado');
        } catch (e) { /* Firefox y Safari antiguos no conocen «microphone»: da igual */ }
      }
    }
    if (f) explicar(f);
    return f;
  }

  // ========================== 3. Escuchar (Web Speech API) ==========================
  // alTexto(texto) mientras hablas · alEstado(true/false) · alFallo({mensaje, escribir}) si algo va mal.
  function crearOido({lang = 'es-ES', alTexto, alEstado, alFallo}) {
    let rec = null, activo = false, cerrado = false, vigia = 0;
    const estado = (si) => { if (activo !== si) { activo = si; alEstado && alEstado(si); } };
    const fallar = (f) => {
      if (cerrado) return;
      cerrado = true; clearTimeout(vigia); explicar(f); estado(false); alFallo && alFallo(f);
    };
    function preparar() {
      rec = new (Reconocedor())();
      rec.lang = lang;
      rec.interimResults = true;              // da resultados mientras hablas, no solo al final
      rec.onstart = () => clearTimeout(vigia);
      rec.onresult = (e) => alTexto && alTexto([...e.results].map((x) => x[0].transcript).join(' '));
      rec.onerror = (e) => fallar(fallo(e.error));
      rec.onend = () => { clearTimeout(vigia); cerrado = true; estado(false); };
    }
    return {
      empezar() {
        cerrado = false;
        try {
          if (!rec) preparar();
          rec.start();
          estado(true);
          // Si en 12 s no ha empezado ni ha dado error, el navegador se ha quedado colgado: a escribir.
          vigia = setTimeout(() => { fallar(fallo('sin-respuesta')); try { rec.abort(); } catch (e) {} }, 12000);
        } catch (e) {
          if (e && e.name === 'InvalidStateError') return;   // ya estaba escuchando
          fallar(fallo(e && e.name === 'NotAllowedError' ? 'not-allowed' : (e && e.name) || 'desconocido'));
        }
      },
      parar() { try { rec && rec.stop(); } catch (e) {} },
      get escuchando() { return activo; },
    };
  }

  // ========================== 4. Encontrar los campos del formulario ==========================
  const limpio = (s) => String(s || '').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase()
    .normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, ' ');
  const REGLAS = [
    ['personas', /\b(personas?|comensales|pax|invitados|adultos|asistentes|guests?|people|party size|cuantos)\b/],
    ['dia', /\b(fecha|dia|date|day|cuando)\b/],
    ['hora', /\b(hora|horario|time|hour)\b/],
    ['nombre', /\b(nombre|name|full name|fullname|titular)\b/],
  ];
  const NUNCA = /^(hidden|submit|button|reset|checkbox|radio|file|email|tel|password|url|color|range|image|search)$/;

  // El texto de las etiquetas, sin lo que haya dentro (opciones de un <select>, por ejemplo)
  function textoEtiqueta(el) {
    return [...(el.labels || [])].map((l) => {
      const c = l.cloneNode(true);
      c.querySelectorAll('input,select,textarea,button').forEach((x) => x.remove());
      return c.textContent;
    }).join(' ');
  }

  function detectarCampos(form) {
    const campos = {};
    const numeros = [];
    for (const el of form.elements) {
      if (!/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) continue;
      const forzado = el.getAttribute('data-voz-campo');
      if (forzado) { if (!campos[forzado]) campos[forzado] = el; continue; }
      const tipo = (el.getAttribute('type') || 'text').toLowerCase();
      if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && NUNCA.test(tipo))) continue;
      const texto = limpio([el.name, el.id, el.placeholder, el.getAttribute('aria-label'), el.autocomplete, textoEtiqueta(el)].join(' '));
      let campo = el.tagName === 'INPUT' && tipo === 'date' ? 'dia' : el.tagName === 'INPUT' && tipo === 'time' ? 'hora' : null;
      if (!campo) for (const [c, re] of REGLAS) if (re.test(texto)) { campo = c; break; }
      if (campo === 'nombre' && /apellido|last name|surname|usuario|user name|empresa|company/.test(texto)) campo = null;
      if (!campo && tipo === 'number') numeros.push(el);
      if (campo && !campos[campo]) campos[campo] = el;
    }
    if (!campos.personas && numeros.length === 1) campos.personas = numeros[0];   // un solo número suelto: son las personas
    return campos;
  }

  // ========================== 5. Rellenar como si lo hubiera escrito una persona ==========================
  // Con el «setter» nativo y los eventos input y change, React, Vue y las validaciones se enteran del cambio.
  function poner(el, valor) {
    if (valor == null || el.value === String(valor)) return false;
    const proto = el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
    Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, valor);
    el.dispatchEvent(new Event('input', {bubbles: true}));
    el.dispatchEvent(new Event('change', {bubbles: true}));
    return true;
  }

  const minutos = (s) => { const m = String(s).match(/(\d{1,2})[:.h](\d{2})?/); return m ? +m[1] * 60 + +(m[2] || 0) : NaN; };

  // En un <select>, la opción que mejor encaja con lo que se ha dicho
  function opcion(sel, campo, v) {
    const ops = [...sel.options].filter((o) => !o.disabled && o.value !== '');
    if (campo === 'personas') return ops.find((o) => +(o.value.match(/\d+/) || [])[0] === v || +(o.text.match(/\d+/) || [])[0] === v);
    if (campo === 'hora') {   // la hora exacta o, si no está, la más cercana
      const quiero = minutos(v);
      let mejor = null, dist = 61;
      for (const o of ops) { const m = minutos(o.value) || minutos(o.text); const x = Math.abs(m - quiero); if (x < dist) { dist = x; mejor = o; } }
      return mejor;
    }
    const buscado = limpio(v).trim();
    return ops.find((o) => o.value === String(v) || limpio(o.text).trim() === buscado) || ops.find((o) => limpio(o.text).includes(buscado));
  }

  function valorPara(el, campo, v, lang) {
    if (el.tagName === 'SELECT') { const o = opcion(el, campo, v); return o ? o.value : null; }
    const tipo = (el.getAttribute('type') || 'text').toLowerCase();
    if (campo === 'dia' && tipo !== 'date') {   // en un campo de texto, la fecha como la escribe la gente: 18/10/2026
      const [a, m, d] = v.split('-').map(Number);
      return new Date(a, m - 1, d).toLocaleDateString(lang, {day: '2-digit', month: '2-digit', year: 'numeric'});
    }
    return String(v);
  }

  function rellenar(campos, r, opciones = {}) {
    const lang = opciones.lang || 'es-ES';
    for (const campo of ['personas', 'dia', 'hora', 'nombre']) {
      const el = campos[campo];
      if (!el || r[campo] === undefined) continue;
      if (poner(el, valorPara(el, campo, r[campo], lang))) {
        el.classList.remove('vozf-lleno'); void el.offsetWidth; el.classList.add('vozf-lleno');   // un destello
        if (opciones.color) el.style.setProperty('--vozf-c', opciones.color);
      }
    }
  }

  // ========================== 6. El botón (en Shadow DOM: ni rompe tu CSS ni tu CSS lo rompe) ==========================
  const MIC = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 15a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v7a3 3 0 0 0 3 3zm6-3a6 6 0 0 1-12 0H4a8 8 0 0 0 7 7.9V22h2v-2.1a8 8 0 0 0 7-7.9z"/></svg>';
  const CSS = `
    :host{display:block;margin:0 0 1em;font:inherit;color:inherit;line-height:1.4}
    :host([hidden]){display:none}
    *{box-sizing:border-box}
    .raiz{--c:var(--vozf-color,currentColor);text-align:left;text-transform:none;letter-spacing:normal;font-style:normal}
    .voz{display:flex;align-items:center;gap:12px}
    [hidden]{display:none!important}
    .mic{all:unset;position:relative;flex:none;width:52px;height:52px;border-radius:50%;display:grid;place-items:center;cursor:pointer;
      color:var(--c);border:2px solid var(--c);transition:transform .15s,background-color .2s}
    .mic svg{width:24px;height:24px}
    .mic:focus-visible{outline:3px solid var(--c);outline-offset:3px}
    .mic:active{transform:scale(.9)}
    .mic::before,.mic::after{content:'';position:absolute;inset:-2px;border-radius:50%;border:2px solid var(--c);opacity:0;pointer-events:none}
    .oyendo .mic{background:rgba(127,127,127,.15);background:color-mix(in srgb,var(--c) 16%,transparent)}
    .oyendo .mic::before{animation:anillo 1.6s ease-out infinite}
    .oyendo .mic::after{animation:anillo 1.6s ease-out .8s infinite}
    @keyframes anillo{from{transform:scale(1);opacity:.8}to{transform:scale(1.7);opacity:0}}
    .txt{min-width:0;display:grid;gap:2px}
    .estado{font-size:.9em;font-weight:600;opacity:.8}
    .oido{font-weight:600;overflow-wrap:anywhere}
    .oido:empty{display:none}
    .escribir{display:grid;gap:6px}
    .aviso{margin:0;font-size:.9em;opacity:.85}
    .fila{display:flex;gap:8px}
    .frase{flex:1;min-width:0;font:inherit;color:inherit;background:transparent;padding:.55em .7em;border-radius:8px;
      border:1px solid currentColor;border:1px solid color-mix(in srgb,currentColor 40%,transparent)}
    .leer{all:unset;cursor:pointer;font-weight:700;padding:.55em .9em;border-radius:8px;color:var(--c);border:2px solid var(--c)}
    .leer:focus-visible,.frase:focus-visible{outline:3px solid var(--c);outline-offset:2px}
    @media (prefers-reduced-motion:reduce){.oyendo .mic::before,.oyendo .mic::after{animation:none}}`;

  // El destello del campo relleno vive fuera del Shadow DOM: una clase con prefijo propio para no chocar con nada
  function estilosGlobales() {
    if (document.getElementById('vozf-estilos')) return;
    const s = document.createElement('style');
    s.id = 'vozf-estilos';
    s.textContent = '.vozf-lleno{animation:vozf-brillo 1.4s ease-out}'
      + '@keyframes vozf-brillo{0%,40%{outline:3px solid var(--vozf-c,currentColor);outline-offset:2px}100%{outline:3px solid transparent;outline-offset:2px}}';
    document.head.appendChild(s);
  }

  function ejemplo(campos) {
    return [campos.personas && 'mesa para dos', campos.dia && 'mañana', campos.hora && 'a las nueve', campos.nombre && 'a nombre de Laura']
      .filter(Boolean).join(' ');
  }

  // ========================== 7. Juntarlo todo en un <form data-voz> ==========================
  function iniciar(form) {
    if (form.vozFormulario) return form.vozFormulario;
    const campos = detectarCampos(form);
    if (!Object.keys(campos).length) {
      console.info('[voz-formulario] No encuentro campos de personas, día, hora o nombre en este formulario: márcalos con data-voz-campo="personas|dia|hora|nombre".', form);
      return null;
    }
    const lang = form.getAttribute('data-voz-lang') || document.documentElement.lang || 'es-ES';
    const color = form.getAttribute('data-voz-color') || '';
    const horario = form.getAttribute('data-voz-horario') || (campos.personas ? 'restaurante' : 'dia');
    estilosGlobales();

    const host = document.createElement('voz-formulario');
    const raizSombra = host.attachShadow({mode: 'open'});
    raizSombra.innerHTML = `<style>${CSS}</style><div class="raiz">
      <div class="voz"><button class="mic" type="button" aria-pressed="false" aria-label="Rellenar el formulario hablando">${MIC}</button>
        <div class="txt"><span class="estado"></span><span class="oido" aria-live="polite"></span></div></div>
      <div class="escribir" hidden><p class="aviso" role="status"></p>
        <div class="fila"><input class="frase" aria-label="Escribe la frase" placeholder="${ejemplo(campos)}…"><button class="leer" type="button">Rellenar</button></div></div></div>`;
    const $ = (s) => raizSombra.querySelector(s);
    if (color) $('.raiz').style.setProperty('--vozf-color', color);
    const AYUDA = 'Toca y dilo: «' + ejemplo(campos) + '»';
    $('.estado').textContent = AYUDA;

    const entender = (texto) => rellenar(campos, interpretar(texto, new Date(), horario), {lang, color});
    const aEscribir = (f) => {
      if (oido.escuchando) oido.parar();
      $('.voz').hidden = true; $('.escribir').hidden = false; $('.aviso').textContent = f.mensaje;
    };
    const oido = crearOido({
      lang,
      alTexto: (texto) => { $('.oido').textContent = '«' + texto + '»'; entender(texto); },
      alEstado: (si) => {
        $('.raiz').classList.toggle('oyendo', si);
        $('.mic').setAttribute('aria-pressed', si);
        $('.estado').textContent = si ? 'Escuchando…' : AYUDA;
      },
      alFallo: (f) => f.escribir ? aEscribir(f) : ($('.estado').textContent = f.mensaje),
    });
    $('.mic').addEventListener('click', () => {
      if (oido.escuchando) return oido.parar();
      $('.oido').textContent = '';
      oido.empezar();
    });
    $('.frase').addEventListener('input', (e) => entender(e.target.value));
    $('.frase').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); entender(e.target.value); } });
    $('.leer').addEventListener('click', () => entender($('.frase').value));

    const sitio = form.querySelector('[data-voz-aqui]');
    if (sitio) sitio.appendChild(host); else form.prepend(host);
    comprobar().then((f) => f && aEscribir(f));

    form.vozFormulario = {campos, oido, entender, host};
    return form.vozFormulario;
  }

  // Busca los <form data-voz> al cargar y también los que aparezcan después (webs hechas con React, Vue…)
  function buscar() { document.querySelectorAll('form[data-voz]').forEach(iniciar); }
  let pendiente = false;
  function vigilar() {
    buscar();
    new MutationObserver(() => {
      if (pendiente) return;
      pendiente = true;
      requestAnimationFrame(() => { pendiente = false; buscar(); });
    }).observe(document.documentElement, {childList: true, subtree: true});
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', vigilar); else vigilar();

  // Lo mismo que usa el script, por si quieres montar tu propia interfaz (como demo.html)
  window.VozFormulario = {interpretar, comprobar, crearOido, iniciar, detectarCampos, rellenar};
})();
  • Funciona en Chrome, Edge, Safari (Mac y iPhone) y Samsung Internet. En Firefox, y si algo falla, pasa solo a escribir la frase.
  • Tu web necesita HTTPS (casi todas lo tienen): sin él los navegadores no dejan usar el micrófono.
  • Privacidad: en Chrome el audio se procesa en servidores de Google. Avísalo en tu política de privacidad.
  • Sin librerías ni claves de API. Entiende frases en español.

¿Lo quieres en tu web y no te ves con fuerzas?

En AEWorks lo montamos por ti, junto con lo que necesite tu negocio.
Hablemos →

Ver el vídeo ↗