
Chismes del Lince 08: Camina con la Gata Montesa, un metrónomo con IA y sensores del móvil
El paso del Lince
Quien ha subido alguna vez a Sierra Bermeja de noche sabe que el Lince no corre. Camina. Pone una pata detrás de otra, siempre igual, sin prisa y sin pausa, y por eso llega donde otros se cansan.
Tiene una regla que le enseñó su abuela: «Si pierdes el paso, busca el ritmo». Cuando el camino se pone difícil, el Lince no piensa en las piedras. Piensa en el tic, tic, tic de su propio corazón, y las patas lo siguen solas.
Esta noche la Gata Montesa ha bajado de la lumbre para acompañar a alguien que camina despacio. No le da la mano ni le dice por dónde ir. Solo marca el compás, y de vez en cuando le susurra: «Vas muy bien de ritmo».
Introducción
En los chismes anteriores el móvil escuchaba (Chisme 05), medía la luz (Chisme 04), veía (Chisme 06) y hablaba con la Gata (Chismes 02, 03 y 07).
Ahora vamos a usar otra cosa que lleva dentro y casi nunca miramos: el acelerómetro, el sensor que nota cómo se mueve el móvil. Con él contaremos pasos.
La aplicación hace tres cosas:
- Marca un tic regular, con sonido y, si el móvil puede, con vibración, para caminar «al compás».
- Cuenta los pasos de la persona y calcula a qué ritmo camina de verdad.
- Le habla de vez en cuando («Un poco más ligero, siguiendo el tic») y, al terminar el paseo, le felicita con un mensaje que escribe Gemini.
La idea nació pensando en personas con Parkinson y en personas mayores. En fisioterapia se usan desde hace años las llamadas señales rítmicas externas (en inglés, cueing): un pulso que se oye o se siente y ayuda a algunas personas a mantener el paso. No prometemos nada con esto. Lo que hacemos es construir la herramienta para poder probarla y aprender.
Las reglas de diseño son las de la Gata del Chisme 07, adaptadas a caminar:
- Un botón enorme para empezar y parar.
- Botones grandes para subir y bajar el ritmo, sin escribir nada.
- Freno anti doble toque, porque con temblor es fácil pulsar dos veces sin querer.
- Todo lo que dice la Gata, también escrito en grande.
⚠️ Esto es una prueba técnica, no un producto ni un tratamiento
Esta aplicación no es un producto sanitario ni sustituye a un fisioterapeuta. No sabemos si le sirve a alguien en concreto; queremos comprobar si técnicamente funciona: si el tic suena bien, si se cuentan bien los pasos, si los botones son cómodos.
El ritmo lo decide un profesional. Cada persona tiene un ritmo distinto, y uno demasiado rápido puede ser peor que ninguno. Antes de usar la aplicación con una persona con Parkinson, hay que hablarlo con su fisioterapeuta o su médico.
Se camina en un sitio seguro y, a ser posible, acompañado. Un móvil que suena no sustituye a mirar dónde se pisa.
Qué sale del móvil: los pasos y el sensor no salen del dispositivo. Solo al terminar, un resumen del tipo «12 minutos, 1100 pasos, 95 por minuto» viaja a Cloudflare y Gemini. No contiene nombre ni datos personales.
Cómo funciona por dentro
El recorrido completo es este:
Botón → tic (Web Audio + vibración) → la persona camina → acelerómetro → detector de pasos → ritmo real → Gata (voz local) → al terminar: Worker → Gemini → mensaje final
Fíjate en una cosa. Casi todo ocurre dentro del móvil. El tic, el conteo de pasos, la medición del ritmo y las frases durante el paseo no necesitan internet ni gastan cuota de Gemini. Solo la última felicitación usa la IA.
Las piezas son cuatro:
| Pieza | Qué hace | Tecnología |
|---|---|---|
| Metrónomo | Marca el compás | AudioContext de Web Audio y navigator.vibrate |
| Detector de pasos | Cuenta pasos y calcula el ritmo | Evento devicemotion (acelerómetro) |
| Voz de la Gata | Anima con frases cortas | speechSynthesis |
| Felicitación final | Un mensaje personalizado al terminar | Worker de Cloudflare + Gemini |
Las tres ideas que hay que entender
1. Un metrónomo que no se descompase
Lo primero que se le ocurre a cualquiera es esto:
setInterval(sonarTic, 600);
Funciona… más o menos. El problema es que JavaScript no garantiza que ese temporizador salte justo a los 600 milisegundos. Si el móvil está ocupado, salta con retraso. El tic queda desigual, y un metrónomo que no es regular no sirve para nada.
La solución es una técnica clásica de Web Audio. El audio tiene su propio reloj (ctx.currentTime), mucho más preciso que el del resto de la página. Así que no decimos «suena ahora». Decimos «suena exactamente en este instante futuro»:
- Un temporizador se despierta cada 30 ms.
- Mira qué tics caen en los próximos 150 ms.
- Los deja programados en el reloj de audio (
o.start(t)).
Aunque el temporizador se retrase un poco, los tics ya están programados y suenan clavados. Es la diferencia entre pedir un tic y reservar un tic.
2. Contar pasos con un acelerómetro
Cada vez que apoyas el pie, el cuerpo da un pequeño golpe hacia arriba. El móvil, en el bolsillo, lo nota como un pico en la aceleración. Contar pasos es contar esos picos. Lo difícil es no contar de más ni de menos. El detector sigue estos pasos:
- Calcular el tamaño total de la aceleración (
Math.hypot(x, y, z)). Así da igual cómo esté colocado el móvil. - Quitar la gravedad. Estando quieto el móvil mide siempre unos 9,8. Calculamos ese valor «de base» con una media lenta y lo restamos. Lo que sobra es el movimiento.
- Suavizar con un filtro sencillo, para quitar los temblores rápidos del propio sensor.
- Umbral que se adapta. No fijamos «un paso es más de 2». Miramos lo fuerte que son los golpes recientes (
env) y decimos que es un paso si supera casi la mitad de eso. Así funciona igual con una persona que pisa fuerte que con otra que arrastra los pies. - Buscar el pico: el valor anterior es mayor que el anterior a ese y mayor o igual que el actual.
- Tiempo mínimo entre pasos (300 ms). Es imposible dar más de 200 pasos por minuto, así que un pico que llega antes es un rebote y se ignora.
Con los instantes de los últimos pasos calculamos el ritmo con la mediana de los tiempos entre pasos, no con la media. Un solo pico perdido o repetido no estropea la mediana.
Un aviso honesto. Esto es un detector casero. Los móviles tienen contadores de pasos mucho mejores, pero no son accesibles desde una página web. Con el móvil en un bolsillo o en un cinturón funciona razonablemente. Con el móvil en la mano, el temblor de la mano cuenta como pasos. Por eso la aplicación se piensa para llevarla guardada.
3. ¿Por qué el ritmo no cambia solo?
La primera idea era: «Si la persona va más despacio, la app baja el tic sola». Lo descartamos. Un tic que cambia de velocidad por su cuenta descoloca a quien camina. El ritmo es una ayuda y tiene que ser predecible. Decide la persona, con los botones, o se calcula una sola vez con Medir mi paso:
- Se camina 20 segundos sin tic.
- La app cuenta los pasos, calcula el ritmo natural de la persona (
pasos × 3por minuto) y pone el tic un 10 % por encima, que es una referencia habitual en los estudios de señales rítmicas. - La persona (o su fisioterapeuta) puede corregirlo con los botones + y −.
El ritmo inicial es de 90 tics por minuto. Un adulto sano suele caminar entre 100 y 120 pasos por minuto, pero una persona mayor o con pasos cortos puede ir bastante más lenta. Por eso la medición es la parte más útil de la aplicación.
Paso 1: crea un Worker (o reutiliza el del Chisme 07)
Si ya hiciste el Chisme 07, ya tienes lo que necesitas: el Worker gata-voz sirve tal cual, sin cambiarle nada. Salta al Paso 4.
Si no lo hiciste, en Cloudflare:
Workers y Pages → Crear → Crear Worker
Llámalo gata-voz y pulsa Desplegar.
Paso 2: guarda tu clave como secreto
En Configuración → Variables y secretos → Añadir:
- Nombre:
GEMINI_API_KEY - Valor: la clave de la API de Gemini de los chismes anteriores.
- Tipo: Secreto.
La clave nunca va en el HTML. La página solo conoce la dirección del Worker, y la clave solo la conoce el Worker.
Paso 3: el código del Worker
Pulsa Editar código y sustituye todo lo que haya por el código de la sección WORKER de esta entrada. Solo tienes que revisar dos líneas:
Tu dominio. El Worker solo acepta peticiones de las páginas que estén en esta lista. Pon el tuyo, con y sin www:
const ORIGENES = ['https://montesteam.org', 'https://www.montesteam.org'];
Si tu dominio es distinto, o si pruebas la página desde otro sitio, saldrá el error Failed to fetch.
Los modelos. Es la lista de modelos de Gemini por orden de preferencia:
const MODELOS = ['gemini-3.8-flash', 'gemini-3.6-flash', 'gemini-3.5-flash'];
Si Google retira o cambia algún modelo, esta es la línea que hay que actualizar. Si un modelo agota su cuota diaria, el Worker pasa al siguiente.
Pulsa Desplegar y copia la dirección del Worker, algo como:
https://gata-voz.tu-usuario.workers.dev
¿Y las instrucciones de la Gata? Ya vienen en el Worker (variable
BASE): habla con cariño, en frases cortas, no da consejos médicos y, si alguien dice que se encuentra mal, le indica que llame al 112 o avise a su familia. Para esta aplicación no hay que tocarlas. El mensaje final lo pedimos desde la página, con una frase como «Acabo de terminar un paseo de 12 minutos…», y el Worker le añade su personalidad.
Paso 4: la página
Copia el código de la sección HTML y guárdalo como chisme08ritmo.html. Busca esta línea (está al principio del <script>):
const WORKER = 'https://gata-voz.TU-USUARIO.workers.dev';
Y cambia la dirección por la de tu Worker. Es lo único que hay que tocar.
Si no quieres usar la IA, deja esa línea como está. Cuando la petición falle, la página cae en un
catchy enseña un resumen sencillo («Buen paseo. 1100 pasos en 12 minutos»). Todo lo demás funciona igual, sin cuenta de Cloudflare ni de Gemini. Es una buena forma de empezar.
Paso 5: súbela y ábrela
Sube el archivo a la carpeta app de tu sitio y ábrelo directamente en su dirección (no dentro de una entrada de WordPress), por ejemplo:
https://montesteam.org/app/chisme08ritmo.html
Tiene que ser https. Sin https, el navegador no da acceso a los sensores de movimiento. Si la página está incrustada en un iframe, los sensores también pueden quedar bloqueados.
Paso 6: pruébala (sin salir de casa)
Haz estas pruebas por orden. Si algo falla, sabrás en qué eslabón está el problema.
- El tic. Pulsa Empezar a caminar. Tiene que sonar un tic regular. Cambia el ritmo con + y − mientras suena.
- La vibración. En Android debe vibrar con cada tic. En iPhone Safari no vibra: es normal.
- El detector. Con el móvil en el bolsillo, camina por el pasillo. El contador de pasos debería subir. Cuenta 20 pasos reales y compara.
- La medición. Pulsa Medir mi paso y camina 20 segundos. Al terminar, la app tiene que decirte tu ritmo y ajustar el tic.
- Las frases. Pasados 15 segundos, la Gata debería comentar tu ritmo. Camina más rápido o más despacio que el tic y comprueba que cambia el mensaje.
- El final. Pulsa Parar tras un paseo de más de diez pasos. Tiene que aparecer el mensaje de Gemini (o el resumen sencillo si no has puesto el Worker).
Cuando algo no funciona
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| No suena el tic | Volumen bajo o, en iPhone, interruptor de silencio activado (el audio web también se silencia) | Sube el volumen y quita el modo silencio |
| No cuenta pasos | Permiso de movimiento denegado o página sin https | En iPhone, acepta el permiso al pulsar el botón; comprueba la dirección |
| Cuenta pasos de más | Móvil en la mano (temblor) | Llévalo guardado en un bolsillo o cinturón |
| Cuenta pasos de menos | Paso muy suave | Baja el .45 de la línea del detector (por ejemplo a .35) |
| No vibra | iPhone o navegador sin soporte | Es normal en iPhone; el sonido y la pantalla siguen |
| Se apaga la pantalla | Navegador sin Wake Lock | Ajusta el tiempo de bloqueo del móvil durante la prueba |
| Failed to fetch | Tu dominio no está en ORIGENES | Corrígelo en el Worker (con y sin www) |
| Error 429 | Cuota diaria de Gemini agotada | Espera a mañana o deja que salga el resumen sencillo |
Recuerda lo que aprendimos en el Chisme 07: antes de sospechar del código, comprueba lo más tonto (volumen, permisos, dirección). Mira la cadena eslabón a eslabón.
Los límites: cuánto da la prueba
- La felicitación final gasta una petición por paseo. Con la capa gratuita (unas 20 al día) hay margen, si no se ha gastado antes en otras pruebas.
- El detector de pasos es casero. Es suficiente para aprender y para una prueba técnica, no para medir con precisión.
- Con la pantalla apagada, la página web se para. Por eso la app intenta mantenerla encendida durante el paseo, y por eso no la recomendamos para un uso prolongado.
- El temblor puede confundir al sensor. Una pregunta de fondo que aparecerá al probarla con personas reales es la misma del Chisme 07: ¿qué pasa cuando la máquina no entiende bien a la persona que más la necesita?
Para el aula
- Calibra el detector. Cada grupo camina 50 pasos reales con el móvil en un bolsillo distinto (delantero, trasero, cinturón) y anota cuántos cuenta la app. ¿Dónde acierta más?
- Haz una gráfica. Añade una línea que guarde el valor de
smen una lista y dibújalo (con Processing, con Python o con una hoja de cálculo). Los picos son los pasos. - Ajusta el umbral. ¿Qué pasa si cambias
.45por.2o por.8? ¿Y el tiempo mínimo de 300 ms? - Rompe el metrónomo. Sustituye la programación por un simple
setIntervaly mide con el móvil cuánto se desvía el tic al abrir otra pestaña. - Piensa en la ética. ¿Qué tres preguntas harías a un fisioterapeuta antes de probar esto con una persona? ¿Qué datos no deberíamos guardar nunca?
https://montesteam.org/app/chisme08ritmo.html

El siguiente chisme
La aplicación cuenta pasos, pero no sabe si son largos o cortos, que es justo lo que más importa en el Parkinson. Es muy difícil de medir con un móvil en un bolsillo, y es un buen reto: ¿podría la cámara ayudar, sin que la imagen salga del dispositivo?
Y quedan pendientes cosas que ya se han ido apuntando: una voz más natural, que la persona pueda hablar sin pausas artificiales y, para el Technovation, unir cámara y voz.
Conclusión
Con un poco de física y muy poca IA hemos construido algo útil de verdad:
tic → pasos → ritmo → voz → Gemini (solo al final)
Lo más interesante no es la IA. Es que un móvil normal, sin instalar nada, lleva un metrónomo de precisión y un sensor de movimiento, y que con unas decenas de líneas de JavaScript se pueden juntar para ayudar a alguien.
Lo difícil vuelve a ser lo de siempre: decidir cómo, con quién y para qué. El Lince lo sabe: no importa correr más, importa llevar el paso.
⚠️ ANTES DE PUBLICAR NADA, LEE ESTO
No es un producto sanitario. Es una prueba técnica de un centro educativo. No la presentes como terapia ni prometas beneficios que no hemos comprobado.
El ritmo lo decide un profesional. Si la usa una persona con Parkinson u otra patología, tiene que saberlo su fisioterapeuta o su médico. La aplicación puede sugerir un ritmo, pero no sabe nada de la persona.
Caminar con seguridad. Sitio llano y conocido, calzado adecuado y, a ser posible, con compañía. Si hay mareo, dolor o cansancio, se para.
Una IA puede equivocarse. El mensaje final de la Gata lo escribe Gemini y puede decir cosas inadecuadas. Lo hemos limitado con reglas en el Worker (no da consejos médicos), pero conviene revisar lo que responde antes de enseñárselo a nadie.
Tus datos. Los sensores no salen del móvil. Solo viaja el resumen final (minutos, pasos y ritmo). Aun así, pide el consentimiento de la persona y de su familia, y no incluyas nunca nombres ni datos de salud en el resumen.
Tu clave de API es tuya. No la pongas en el HTML ni la subas a un repositorio público. Si sospechas que se ha filtrado, genera una nueva.
Sin código de acceso, el Worker está abierto a quien conozca su dirección. Limita el dominio permitido, vigila el consumo y no lo dejes publicado más tiempo del necesario.
Si lo usas en un centro educativo, revisa las condiciones de uso de los servicios y las indicaciones de la persona responsable de protección de datos.
La tecnología es la parte fácil. Decidir cómo, con quién y para qué la usamos es el verdadero chisme.
HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b6b3a">
<title>Chisme 08 · Camina con la Gata Montesa</title>
<style>
:root{--bg:#eef4ee;--card:#fbfdfb;--ink:#16261b;--mute:#4f6355;--line:#c9dccd;--pine:#0b6b3a;--ocre:#b9770e;--ok:#2f6b3a;--bad:#8a1c1c}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:20px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom)}
header{background:var(--pine);color:#fff;padding:14px 18px;border-bottom:10px solid #fff;box-shadow:0 10px 0 var(--pine);margin-bottom:10px}
header h1{margin:0;font-size:1.45rem}
main{max-width:680px;margin:0 auto;padding:14px}
.aviso{background:#f6e3df;border:1px solid #dfb1a8;color:var(--bad);border-radius:10px;padding:10px 13px;font-size:.85rem;margin:0 0 14px}
button{font:inherit;font-weight:700;border:0;border-radius:14px;cursor:pointer;color:#fff;background:var(--pine)}
button:focus-visible{outline:4px solid var(--ocre);outline-offset:3px}
#ritmo{display:grid;grid-template-columns:96px 1fr 96px;gap:10px;align-items:center;margin:0 0 14px}
#ritmo button{height:96px;font-size:3rem;line-height:1}
#bpm{background:var(--card);border:3px solid var(--pine);border-radius:20px;text-align:center;padding:12px 4px;transition:transform .08s}
#bpm.tic{transform:scale(1.05);background:#d8efdc}
#bpm b{display:block;font-size:3.6rem;line-height:1}
#bpm span{color:var(--mute);font-size:.95rem}
#go{width:100%;min-height:26vh;font-size:2.2rem;padding:16px;margin:0 0 14px}
#go[data-e="on"]{background:var(--bad)}
#texto{min-height:5.5em;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;font-size:1.3rem;margin:0 0 14px;overflow-wrap:anywhere}
#texto.err{color:var(--bad)}
.dat{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:0 0 14px;text-align:center}
.dat div{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:8px 2px}
.dat b{display:block;font-size:1.7rem}.dat span{font-size:.8rem;color:var(--mute)}
.fila{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:0 0 12px}
.fila button{min-height:70px;font-size:1rem;padding:6px;background:var(--card);color:var(--pine);border:2.5px solid var(--pine)}
.fila button[aria-pressed="true"]{background:var(--pine);color:#fff}
#cal{width:100%;min-height:72px;font-size:1.1rem;background:var(--card);color:var(--pine);border:2.5px solid var(--pine)}
</style>
</head>
<body>
<header><h1>Camina con la Gata Montesa</h1></header>
<main>
<p class="aviso"><strong>Prueba técnica, no un tratamiento.</strong> Camina siempre en un sitio seguro y, si puedes, acompañado. Si te mareas o te cansas, para. Pregunta a tu fisioterapeuta qué ritmo te conviene. Al terminar, un resumen sin datos personales viaja a Cloudflare y Gemini.</p>
<div id="ritmo">
<button id="menos" type="button" aria-label="Más despacio">−</button>
<div id="bpm" aria-live="off"><b id="n">90</b><span>tics por minuto</span></div>
<button id="mas" type="button" aria-label="Más deprisa">+</button>
</div>
<button id="go" type="button" data-e="off">Empezar a caminar</button>
<div id="texto" aria-live="polite">Pulsa el botón grande y camina siguiendo el tic: un paso, un tic.</div>
<div class="dat">
<div><b id="tiempo">0:00</b><span>tiempo</span></div>
<div><b id="pasos">0</b><span>pasos</span></div>
<div><b id="cad">–</b><span>tus pasos por minuto</span></div>
</div>
<div class="fila" id="opc"></div>
<button id="cal" type="button">Medir mi paso (20 segundos)</button>
</main>
<script>
const WORKER = 'https://gata-voz.TU-USUARIO.workers.dev'; // el mismo Worker del Chisme 07
const $ = id => document.getElementById(id);
const SR_OK = 'speechSynthesis' in window;
let bpm = 90, corriendo = false, midiendo = false;
let opt = {sonido: true, vibra: 'vibrate' in navigator, voz: true};
let ctx, sig = 0, timer = null, wake = null, t0 = 0, reloj = null, ultimaVoz = 0, bloqueo = 0;
let pasos = 0, marcas = [];
function msg(t, err) { $('texto').textContent = t; $('texto').className = err ? 'err' : ''; }
function decir(t) {
if (!opt.voz || !SR_OK) return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(t); u.lang = 'es-ES'; u.rate = .95;
const v = speechSynthesis.getVoices().find(v => v.lang.toLowerCase().startsWith('es')); if (v) u.voice = v;
speechSynthesis.speak(u);
}
function ponerBpm(n) { bpm = Math.max(60, Math.min(130, n)); $('n').textContent = bpm; }
/* ---------- Metrónomo (Web Audio, programado con antelación para que no se descompase) ---------- */
function tic(t) {
if (opt.sonido) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = 760; o.type = 'triangle';
g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.9, t + .005); g.gain.exponentialRampToValueAtTime(0.0001, t + .09);
o.connect(g).connect(ctx.destination); o.start(t); o.stop(t + .1);
}
setTimeout(() => {
$('bpm').classList.add('tic'); setTimeout(() => $('bpm').classList.remove('tic'), 110);
if (opt.vibra) navigator.vibrate(35);
}, Math.max(0, (t - ctx.currentTime) * 1000));
}
function planificar() {
while (sig < ctx.currentTime + .15) { tic(sig); sig += 60 / bpm; }
}
function arrancaMetronomo() {
ctx = ctx || new (window.AudioContext || window.webkitAudioContext)();
ctx.resume(); sig = ctx.currentTime + .3; timer = setInterval(planificar, 30);
}
function paraMetronomo() { clearInterval(timer); timer = null; }
/* ---------- Detector de pasos (acelerómetro) ---------- */
let base = 9.8, sm = 0, p1 = 0, p2 = 0, env = 1.2, ultimoPaso = 0;
function movimiento(e) {
const g = e.accelerationIncludingGravity; if (!g || g.x == null) return;
const m = Math.hypot(g.x, g.y, g.z);
base += .02 * (m - base);
sm += .35 * ((m - base) - sm);
env = Math.max(1.0, env * .998, Math.abs(sm));
const ahora = performance.now();
if (p1 > p2 && p1 >= sm && p1 > .45 * env && ahora - ultimoPaso > 300) {
if (ahora - ultimoPaso > 1800) marcas = []; // había parado: empezamos de nuevo
ultimoPaso = ahora; pasos++; marcas.push(ahora); marcas = marcas.slice(-9);
if (corriendo || midiendo) $('pasos').textContent = pasos;
}
p2 = p1; p1 = sm;
}
function cadencia() {
if (marcas.length < 5 || performance.now() - ultimoPaso > 2000) return null;
const d = marcas.slice(1).map((t, i) => t - marcas[i]).sort((a, b) => a - b);
return Math.round(60000 / d[Math.floor(d.length / 2)]);
}
async function permisoSensor() {
if (typeof DeviceMotionEvent !== 'undefined' && DeviceMotionEvent.requestPermission) {
const r = await DeviceMotionEvent.requestPermission(); if (r !== 'granted') throw new Error('permiso');
}
window.removeEventListener('devicemotion', movimiento); window.addEventListener('devicemotion', movimiento);
}
/* ---------- Pantalla encendida ---------- */
async function pantalla() { try { wake = await navigator.wakeLock.request('screen'); } catch (_) {} }
/* ---------- Paseo ---------- */
function reloj1() {
const s = Math.floor((performance.now() - t0) / 1000);
$('tiempo').textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
const c = cadencia(); $('cad').textContent = c || '–';
if (corriendo && c && s > 15 && performance.now() - ultimaVoz > 30000) {
ultimaVoz = performance.now(); const r = c / bpm; let f;
if (r > 1.12) f = 'Con calma, sin prisa. Sigue el tic.';
else if (r < .88) f = 'Un poco más ligero, siguiendo el tic.';
else f = 'Vas muy bien de ritmo.';
msg(f); decir(f);
}
}
async function empezar() {
try { await permisoSensor(); } catch (_) { msg('Sin acceso al movimiento no puedo contar pasos, pero el tic sí funciona.', true); }
pasos = 0; marcas = []; ultimaVoz = performance.now(); t0 = performance.now();
corriendo = true; $('go').dataset.e = 'on'; $('go').textContent = 'Parar';
arrancaMetronomo(); pantalla(); reloj = setInterval(reloj1, 1000);
msg('Un paso, un tic. Mira al frente y da pasos grandes.'); decir('Vamos. Un paso, un tic.');
}
async function parar() {
corriendo = false; paraMetronomo(); clearInterval(reloj); if (wake) { wake.release(); wake = null; }
$('go').dataset.e = 'off'; $('go').textContent = 'Empezar a caminar';
const min = Math.max(1, Math.round((performance.now() - t0) / 60000));
const media = Math.round(pasos / ((performance.now() - t0) / 60000));
let fin = `Buen paseo. ${pasos} pasos en ${min} minuto${min > 1 ? 's' : ''}.`;
msg(fin); if (pasos < 10) { decir(fin); return; }
try {
const r = await fetch(WORKER, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({modo: 'cercana', historial: [{role: 'user', content:
`Acabo de terminar un paseo de ${min} minutos con unos ${pasos} pasos, a unos ${media} pasos por minuto. Felicítame con dos frases cortas.`}]})});
const d = await r.json(); if (!r.ok || d.error || !d.texto) throw 0;
fin = d.texto.replace(/[*_#`]/g, '').trim();
} catch (_) { /* sin IA: nos quedamos con el resumen local */ }
msg(fin); decir(fin);
}
/* ---------- Medir mi paso: 20 s sin tic; el ritmo sugerido queda un 10 % por encima ---------- */
async function medir() {
if (corriendo || midiendo) return;
try { await permisoSensor(); } catch (_) { msg('Necesito el permiso de movimiento para medir tu paso.', true); return; }
midiendo = true; pasos = 0; marcas = []; pantalla();
msg('Camina con normalidad. Mido durante 20 segundos.'); decir('Camina con normalidad. Mido durante veinte segundos.');
const ini = performance.now();
const iv = setInterval(() => { $('cad').textContent = cadencia() || '–'; }, 1000);
setTimeout(() => {
clearInterval(iv); midiendo = false; if (wake) { wake.release(); wake = null; }
const media = pasos * 3;
if (pasos < 12) { msg('No he notado bien tus pasos. Guarda el móvil en un bolsillo y repite.', true); return; }
ponerBpm(Math.round(media * 1.1));
const f = `Das unos ${media} pasos por minuto. Pongo el tic en ${bpm}. Puedes cambiarlo con los botones.`;
msg(f); decir(f);
}, 20000);
}
/* ---------- Botones (con freno anti doble toque, por el temblor) ---------- */
function unico(fn) { return () => { const n = Date.now(); if (n - bloqueo < 500) return; bloqueo = n; fn(); }; }
$('go').onclick = unico(() => { if (midiendo) return; corriendo ? parar() : empezar(); });
$('mas').onclick = unico(() => ponerBpm(bpm + 5));
$('menos').onclick = unico(() => ponerBpm(bpm - 5));
$('cal').onclick = unico(medir);
[['sonido', 'Sonido'], ['vibra', 'Vibración'], ['voz', 'Voz de la Gata']].forEach(([k, n]) => {
if (k === 'vibra' && !('vibrate' in navigator)) { const b = document.createElement('button'); b.textContent = 'Vibración no disponible'; b.disabled = true; $('opc').appendChild(b); return; }
const b = document.createElement('button'); b.textContent = n; b.setAttribute('aria-pressed', opt[k]);
b.onclick = unico(() => { opt[k] = !opt[k]; b.setAttribute('aria-pressed', opt[k]); if (k === 'voz' && !opt.voz) speechSynthesis.cancel(); });
$('opc').appendChild(b);
});
document.addEventListener('visibilitychange', () => { if (corriendo && document.visibilityState === 'visible') pantalla(); });
</script>
</body>
</html>
WORKER
// Worker "gata-voz": Gata Montesa con modos de voz.
// Secreto necesario: GEMINI_API_KEY
// ⚠️ Cambia esto por el dominio donde vive la página
const ORIGENES = ['https://montesteam.org', 'https://www.montesteam.org'];
// Si un modelo agota su cuota diaria, se prueba el siguiente
const MODELOS = ['gemini-3.8-flash', 'gemini-3.6-flash', 'gemini-3.5-flash'];
// CAPA 1: quién es y reglas que ningún modo puede cambiar
const BASE =
'Eres la Gata Montesa, compañera de voz del proyecto MonteSTeam (Estepona, Málaga). ' +
'Hablas con una persona mayor. SIEMPRE eres amable, paciente y respetuosa; la tratas de usted ' +
'salvo que ella te tutee. Tu respuesta se LEERÁ EN VOZ ALTA: frases cortas, sin listas, ' +
'sin asteriscos, sin emojis, sin paréntesis; escribe los números y las horas como se dicen. ' +
'Responde en 2 o 3 frases y termina, cuando encaje, con una pregunta sencilla para seguir la charla. ' +
'Si acaba de escuchar un cuento y comenta algo, responde con cariño a lo que dice. ' +
'No das consejos médicos ni cambias tratamientos: ante dudas de salud, recomiendas hablar con ' +
'su médico o su familia. Si dice que se encuentra mal o que se ha caído, dile con calma que ' +
'llame al 112 o avise a alguien de su familia. Si no entiendes, lo dices con cariño y pides que repita. ' +
'No pidas ni guardes datos personales.';
// CAPA 2: cómo habla (el navegador solo envía el id del modo)
const MODOS = {
cercana:
'MODO CERCANA: habla con humor amable y un toque andaluz, como una vecina de confianza.',
cuentos:
'MODO CUENTACUENTOS: voz pausada y cálida, como junto a la lumbre. Usa imágenes sencillas ' +
'y un ritmo lento y tranquilo.',
radio:
'MODO LOCUTORA DE RADIO: alegre y animada, con energía de programa de las mañanas de antes, ' +
'sin gritar nunca.',
};
const esperar = ms => new Promise(r => setTimeout(r, ms));
export default {
async fetch(request, env) {
const cab = {
'Access-Control-Allow-Origin': ORIGENES.includes(request.headers.get('Origin')) ? request.headers.get('Origin') : ORIGENES[0],
'Vary': 'Origin',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
const json = { ...cab, 'Content-Type': 'application/json' };
const fallo = (status, etapa, detalle) =>
new Response(JSON.stringify({ error: true, etapa, detalle }), { status, headers: json });
if (request.method === 'OPTIONS') return new Response(null, { headers: cab });
if (request.method !== 'POST') return fallo(405, 'Worker', 'Método no permitido');
let c;
try { c = await request.json(); } catch { return fallo(400, 'Worker', 'JSON inválido'); }
const historial = Array.isArray(c.historial) ? c.historial.slice(-12) : [];
if (!historial.length) return fallo(400, 'Worker', 'Falta el mensaje');
const sistema = BASE + '\n\n' + (MODOS[c.modo] || MODOS.cercana);
const contents = historial.map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: String(m.content).slice(0, 2000) }],
}));
let ultimo = '', cuota = false;
for (const modelo of MODELOS) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelo}:generateContent`;
for (let intento = 1; intento <= 2; intento++) {
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': env.GEMINI_API_KEY },
body: JSON.stringify({ system_instruction: { parts: [{ text: sistema }] }, contents }),
});
if (r.ok) {
const d = await r.json();
const texto = d?.candidates?.[0]?.content?.parts?.map(p => p.text).join('') || '';
return new Response(JSON.stringify({ texto, modelo }), { headers: json });
}
ultimo = `${modelo} → HTTP ${r.status}: ${(await r.text().catch(() => '')).slice(0, 300)}`;
if (r.status === 429) { cuota = true; break; } // cuota agotada: siguiente modelo
if (r.status === 408 || r.status >= 500) { await esperar(1500); continue; } // saturado: reintento
return fallo(502, 'Gemini API', ultimo); // otro error: no insistimos
} catch (e) { ultimo = `${modelo} → Error de red: ${e.message}`; }
}
}
return cuota
? fallo(429, 'Cuota', 'Se ha agotado la cuota gratuita de Gemini por hoy. ' + ultimo)
: fallo(503, 'Gemini API', 'Gemini sigue saturado. ' + ultimo);
},
};



