La web que te obedece por voz
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.
// CÓMO FUNCIONA
- 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.
- 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".
- 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.
- 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.
- 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.
- 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};
})();
// Un formulario normal de cualquier web, sin nada especial: solo la línea del script y data-voz. Guárdalo junto a voz-formulario.js y ábrelo.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Casa Pepa · Reservas</title>
<!-- 1. Esta es la única línea que añades a tu web -->
<script src="voz-formulario.js" defer></script>
<style>
body{font-family:Arial,sans-serif;max-width:460px;margin:24px auto;padding:0 16px;background:#fff;color:#222}
label{display:block;margin:12px 0 4px}
input,select{width:100%;box-sizing:border-box;padding:8px;font-size:16px}
button{margin-top:16px;padding:10px 18px;font-size:16px}
</style>
</head>
<body>
<h1>Casa Pepa</h1>
<p>Reserva tu mesa. Te confirmamos por teléfono.</p>
<!-- 2. Y data-voz en tu formulario. Nada más: los campos son los de siempre -->
<form data-voz>
<label for="nombre">Nombre</label>
<input id="nombre" name="nombre" type="text" autocomplete="name" required>
<label for="telefono">Teléfono</label>
<input id="telefono" name="telefono" type="tel">
<label for="personas">Número de personas</label>
<input id="personas" name="personas" type="number" min="1" max="20" required>
<label for="fecha">Día</label>
<input id="fecha" name="fecha" type="date" required>
<label for="hora">Hora</label>
<select id="hora" name="hora" required>
<option value="">Elige una hora</option>
<option>13:30</option><option>14:00</option><option>14:30</option><option>15:00</option>
<option>20:30</option><option>21:00</option><option>21:30</option><option>22:00</option>
</select>
<button type="submit">Reservar</button>
</form>
</body>
</html>
// La demo del vídeo, con su propio diseño: usa el mismo voz-formulario.js (guárdalos juntos). Con ?demo=1 al final de la dirección se rellena sola.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brasa & Sal · Reserva con tu voz</title>
<style>
:root{--fondo:#05070A;--verde:#00FF7F;--blanco:#E8F5EE;--gris:#7C8A82;--tarjeta:#0B1210;--borde:#1C2A24}
*{box-sizing:border-box}
html{color-scheme:dark}
body{margin:0;min-height:100svh;overflow-x:hidden;background:radial-gradient(ellipse at 50% 0%,#0C2A1B,transparent 60%),var(--fondo);
color:var(--blanco);font-family:system-ui,'Segoe UI',sans-serif;display:grid;place-items:center;padding:clamp(10px,2vh,28px) 16px}
main{width:100%;max-width:1080px;display:grid;gap:clamp(10px,2.2vh,22px)}
@media (min-width:900px){main{grid-template-columns:1.05fr 1fr;gap:56px;align-items:center}}
.marca{display:flex;align-items:center;gap:8px;font:italic 700 clamp(17px,2.4vh,22px) Georgia,serif;letter-spacing:.3px}
.marca svg{width:22px;height:22px;fill:var(--verde);filter:drop-shadow(0 0 6px #00FF7F99)}
.marca small{font:600 11px system-ui,sans-serif;color:var(--gris);letter-spacing:2px;text-transform:uppercase;margin-left:6px;font-style:normal}
h1{margin:clamp(4px,1.2vh,14px) 0 0;font-size:clamp(28px,4.6vh,64px);font-weight:900;letter-spacing:-1.5px;line-height:1}
h1 em{font-style:normal;color:var(--verde);text-shadow:0 0 28px #00FF7F66}
.sub{color:var(--gris);margin:6px 0 0;font-size:15px}
@media (max-width:899px){h1 br{display:none}.sub{display:none}}
/* ---- Micrófono + onda ---- */
.voz[hidden]{display:none}
.voz{display:flex;align-items:center;gap:18px;margin-top:clamp(6px,1.4vh,26px)}
#mic{position:relative;flex:none;width:clamp(76px,12vh,112px);aspect-ratio:1;border-radius:50%;border:0;cursor:pointer;
background:var(--verde);color:var(--fondo);box-shadow:0 0 0 6px #00FF7F22,0 0 40px #00FF7F55;transition:transform .2s}
#mic svg{width:42%;height:42%}
#mic:active,#mic.pulsado{transform:scale(.88)}
#mic::before,#mic::after{content:'';position:absolute;inset:0;border-radius:50%;border:2px solid var(--verde);opacity:0}
.escuchando #mic::before{animation:anillo 1.6s ease-out infinite}
.escuchando #mic::after{animation:anillo 1.6s ease-out .8s infinite}
@keyframes anillo{from{transform:scale(1);opacity:.9}to{transform:scale(1.9);opacity:0}}
.onda{display:flex;align-items:center;gap:5px;height:44px}
.onda i{width:6px;height:100%;border-radius:3px;background:var(--verde);transform:scaleY(.12);transition:transform .3s}
.escuchando .onda i{animation:onda .9s ease-in-out infinite}
.onda i:nth-child(2){animation-delay:-.15s!important}.onda i:nth-child(3){animation-delay:-.5s!important}
.onda i:nth-child(4){animation-delay:-.3s!important}.onda i:nth-child(5){animation-delay:-.7s!important}
.onda i:nth-child(6){animation-delay:-.45s!important}.onda i:nth-child(7){animation-delay:-.1s!important}
@keyframes onda{0%,100%{transform:scaleY(.18)}50%{transform:scaleY(1)}}
#estado{font-size:14px;color:var(--gris);font-weight:600}
.escuchando #estado{color:var(--verde)}
/* ---- Lo que va oyendo ---- */
#oido{margin-top:clamp(6px,1.4vh,20px);transition:border-color .3s,box-shadow .3s;min-height:clamp(62px,9vh,96px);padding:12px 16px;border-radius:16px;background:#0A100E;
border:1px solid var(--borde);font-size:clamp(17px,2.6vh,24px);font-weight:600;line-height:1.35}
.escuchando #oido{border-color:#00FF7F66;box-shadow:0 0 30px #00FF7F1A}
.escuchando #oido::after{content:'';display:inline-block;width:3px;height:1.05em;background:var(--verde);vertical-align:-.18em;animation:parpadeo 1s steps(1) infinite}
@keyframes parpadeo{50%{opacity:0}}
#oido:empty::before{content:'Pulsa y di: «mesa para dos mañana a las nueve, a nombre de Laura»';color:var(--gris);font-weight:500;font-size:15px}
#oido span{display:inline-block;margin-right:.28em}
#oido .nueva{animation:entra .35s cubic-bezier(.2,.9,.3,1.3)}
#oido .clave{color:var(--verde)}
@keyframes entra{from{opacity:0;transform:translateY(10px) scale(.8)}}
/* ---- Formulario ---- */
form{position:relative;background:var(--tarjeta);border:1px solid var(--borde);border-radius:22px;padding:clamp(14px,2.4vh,28px);
display:grid;grid-template-columns:1fr 1fr;gap:clamp(10px,1.6vh,16px);box-shadow:0 30px 80px #0008}
form h2{grid-column:1/-1;margin:0;font-size:13px;letter-spacing:2px;text-transform:uppercase;color:var(--gris)}
label{display:grid;gap:5px;font-size:12px;font-weight:700;color:var(--gris);letter-spacing:.5px;text-transform:uppercase}
.ancho{grid-column:1/-1}
input{width:100%;min-width:0;font:700 17px system-ui,sans-serif;color:var(--blanco);background:#05070A;border:2px solid var(--borde);
border-radius:12px;padding:clamp(8px,1.4vh,12px) 12px;transition:border-color .3s,box-shadow .3s}
input:focus{outline:none;border-color:var(--gris)}
.lleno input{border-color:var(--verde);box-shadow:0 0 0 4px #00FF7F22,0 0 24px #00FF7F44;animation:destello .7s}
.lleno{color:var(--verde)}
@keyframes destello{0%{transform:scale(1);background:#00FF7F33}35%{transform:scale(1.04)}100%{transform:scale(1);background:#05070A}}
#reservar{grid-column:1/-1;font:900 19px system-ui,sans-serif;padding:clamp(12px,1.9vh,17px);border:0;border-radius:14px;cursor:pointer;
background:var(--verde);color:var(--fondo);box-shadow:0 0 30px #00FF7F55;transition:transform .15s}
#reservar:active,#reservar.pulsado{transform:scale(.95)}
/* ---- Ordenador: todo más grande ---- */
@media (min-width:900px){
main{max-width:1180px}
.marca{font-size:26px}
h1{font-size:clamp(56px,10vh,84px);letter-spacing:-2.5px}
.sub{font-size:19px;margin-top:14px}
#mic{width:132px}
.onda{height:70px;gap:7px}.onda i{width:9px;border-radius:5px}
#estado{font-size:17px}
#oido{font-size:28px;min-height:118px;padding:18px 22px;border-radius:20px}
form{padding:34px;gap:20px;border-radius:26px}
label{font-size:14px}
input{font-size:24px;padding:14px 16px;border-radius:14px}
#reservar{font-size:24px;padding:20px}
form #ok b{font-size:52px}form #ok p{font-size:21px}form #ok svg{width:120px;height:120px}
}
/* ---- Confirmación ---- */
#ok{position:absolute;inset:0;border-radius:22px;background:radial-gradient(circle,#0E2A1C,var(--tarjeta) 70%);display:grid;place-content:center;
text-align:center;gap:8px;opacity:0;pointer-events:none;transition:opacity .3s}
#ok.visto{opacity:1;pointer-events:auto}
#ok svg{width:86px;height:86px;margin:0 auto}
#ok circle,#ok path{fill:none;stroke:var(--verde);stroke-width:6;stroke-linecap:round}
#ok circle{stroke-dasharray:252;stroke-dashoffset:252}
#ok path{stroke-dasharray:60;stroke-dashoffset:60}
#ok.visto circle{animation:traza .5s ease-out forwards}
#ok.visto path{animation:traza .35s .4s ease-out forwards}
@keyframes traza{to{stroke-dashoffset:0}}
#ok b{font-size:clamp(26px,4vh,36px);font-weight:900;color:var(--verde);text-shadow:0 0 24px #00FF7F88}
#ok p{margin:0;font-size:16px;font-weight:600;padding:0 12px}
#ok.visto b,#ok.visto p{animation:entra .45s .3s backwards cubic-bezier(.2,.9,.3,1.3)}
/* ---- Sin voz (Firefox, sin HTTPS, micrófono bloqueado…): se escribe la frase ---- */
#sinVoz{margin-top:12px;padding:12px 14px;border-radius:14px;border:1px dashed var(--gris);font-size:14px;color:var(--gris)}
#sinVoz p{margin:0;color:var(--blanco);font-weight:600}
#sinVoz div{display:flex;gap:8px;margin-top:8px}
#sinVoz input{font-size:16px}
#sinVoz button{font:800 15px system-ui;border:0;border-radius:10px;background:var(--verde);color:var(--fondo);padding:0 14px;cursor:pointer}
</style>
</head>
<body>
<main>
<section id="panel">
<div class="marca"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2c1 4 6 6 6 12a6 6 0 0 1-12 0c0-3 2-5 3-6 0 2 1 3 2 3 0-3-1-6 1-9z"/></svg>Brasa & Sal <small>Reservas</small></div>
<h1>Reserva<br> <em>con tu voz</em></h1>
<p class="sub">Pulsa el micrófono y dilo como se lo dirías a un camarero.</p>
<div class="voz">
<button id="mic" type="button" aria-pressed="false" aria-label="Hablar para reservar">
<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>
</button>
<div><div class="onda" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div id="estado">Toca para hablar</div></div>
</div>
<div id="oido" aria-live="polite"></div>
<div id="sinVoz" hidden>
<p id="aviso">Tu navegador no entiende la voz: escribe la frase y la rellenamos igual.</p>
<div><input id="frase" placeholder="mesa para dos mañana a las nueve…" aria-label="Frase de la reserva"><button type="button" id="leer">Leer</button></div>
</div>
</section>
<form id="reserva">
<h2>Tu mesa</h2>
<label id="c-personas">Personas<input id="personas" type="number" min="1" max="20" inputmode="numeric"></label>
<label id="c-hora">Hora<input id="hora" type="time"></label>
<label id="c-dia" class="ancho">Día<input id="dia" type="date"></label>
<label id="c-nombre" class="ancho">A nombre de<input id="nombre" autocomplete="name"></label>
<button id="reservar">Reservar mesa</button>
<div id="ok" role="status"><svg viewBox="0 0 90 90" aria-hidden="true"><circle cx="45" cy="45" r="40"/><path d="M27 46l12 12 24-26"/></svg>
<b>¡Mesa reservada!</b><p id="resumen"></p></div>
</form>
</main>
<!-- El intérprete de frases y la voz a prueba de fallos están en voz-formulario.js (el mismo archivo que se instala en una línea) -->
<script src="voz-formulario.js"></script>
<script>
// ================= 1. EL TRUCO: entender una frase en español (en voz-formulario.js) =================
// interpretar('mesa para dos mañana a las nueve, a nombre de Laura') → {personas: 2, dia: '…', hora: '21:00', nombre: 'Laura'}
const {interpretar} = VozFormulario;
// ================= 2. Rellenar el formulario (y que se ilumine) =================
const $ = (s) => document.querySelector(s);
function rellenar(r) {
for (const campo of ['personas', 'dia', 'hora', 'nombre']) {
const input = $('#' + campo);
if (r[campo] === undefined || String(r[campo]) === input.value) continue;
input.value = r[campo];
const caja = $('#c-' + campo);
caja.classList.remove('lleno'); void caja.offsetWidth; caja.classList.add('lleno'); // reinicia el destello
}
}
// Pinta lo oído palabra a palabra; las palabras «clave» en verde
const CLAVE = /^(\d|un[oa]?$|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|hoy|mañana|pasado|lunes|martes|miércoles|jueves|viernes|sábado|domingo|media|cuarto|noche|tarde|personas?)/i;
let vistas = 0;
function mostrar(texto) {
const r = interpretar(texto);
const nombre = (r.nombre || '').toLowerCase().split(' ');
const palabras = texto.split(/\s+/).filter(Boolean);
$('#oido').innerHTML = palabras.map((w, i) => {
const clave = CLAVE.test(w) || nombre.includes(w.toLowerCase().replace(/[,.]/g, ''));
return `<span class="${i >= vistas ? 'nueva ' : ''}${clave ? 'clave' : ''}">${w}</span>`;
}).join('');
vistas = palabras.length;
rellenar(r);
}
function escuchando(si) {
document.body.classList.toggle('escuchando', si);
$('#mic').setAttribute('aria-pressed', si);
$('#estado').textContent = si ? 'Escuchando…' : 'Toca para hablar';
}
// ================= 3. La voz: Web Speech API, a prueba de fallos =================
// Si la voz no se puede usar (Firefox, sin HTTPS, micrófono bloqueado, dictado apagado en iPhone, sin internet…),
// se dice en una línea y se pasa solo a «escribe la frase», que rellena el formulario igual. Nunca un botón muerto.
const modoDemo = new URLSearchParams(location.search).has('demo');
function aEscribir(mensaje) {
$('#aviso').textContent = mensaje;
$('#sinVoz').hidden = false;
$('.voz').hidden = true;
}
const oido = VozFormulario.crearOido({
lang: 'es-ES',
alTexto: mostrar, // lo que va oyendo, mientras hablas
alEstado: escuchando,
alFallo: (f) => f.escribir ? aEscribir(f.mensaje) : ($('#estado').textContent = f.mensaje), // «No te he oído, prueba otra vez»
});
$('#mic').onclick = () => {
if (oido.escuchando) return oido.parar();
vistas = 0; $('#oido').textContent = '';
oido.empezar();
};
$('#leer').onclick = () => mostrar($('#frase').value);
$('#frase').oninput = (e) => mostrar(e.target.value);
// Antes de nada: ¿hay voz, HTTPS y permiso? Si no, directamente a escribir.
if (!modoDemo) VozFormulario.comprobar().then((f) => f && aEscribir(f.mensaje));
// ================= 4. Reservar =================
$('#reserva').onsubmit = (e) => {
e.preventDefault();
const dia = new Date($('#dia').value + 'T12:00');
$('#resumen').textContent = `${$('#nombre').value || 'Sin nombre'} · ${$('#personas').value || '?'} personas · `
+ `${isNaN(dia) ? '' : dia.toLocaleDateString('es-ES', {weekday: 'long', day: 'numeric', month: 'long'}) + ' · '}${$('#hora').value}`;
$('#ok').classList.add('visto');
setTimeout(() => $('#ok').classList.remove('visto'), 4000);
};
// ================= 5. Modo demostración (demo.html?demo=1) para grabar el vídeo =================
const espera = (ms) => new Promise((ok) => setTimeout(ok, ms));
async function demo() {
const frases = ['mesa para dos mañana a las nueve a nombre de Laura',
'cuatro personas el viernes a las nueve y media de la noche, a nombre de Marcos'];
for (;;) for (const frase of frases) {
await espera(400);
$('#mic').classList.add('pulsado'); await espera(160); $('#mic').classList.remove('pulsado');
escuchando(true); vistas = 0; $('#oido').textContent = '';
await espera(500);
const palabras = frase.split(' ');
for (let i = 1; i <= palabras.length; i++) { mostrar(palabras.slice(0, i).join(' ')); await espera(240); }
await espera(300); escuchando(false);
await espera(500);
$('#reservar').classList.add('pulsado'); await espera(160); $('#reservar').classList.remove('pulsado');
$('#reserva').requestSubmit();
await espera(1900);
$('#ok').classList.remove('visto');
['personas', 'dia', 'hora', 'nombre'].forEach((c) => { $('#' + c).value = ''; $('#c-' + c).classList.remove('lleno'); });
$('#oido').textContent = '';
}
}
if (modoDemo) demo();
</script>
</body>
</html>
- 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.