
Chismes del Lince 10: El Entrenador de Voz
Sierra Bermeja, las tres de la madrugada. Una sombra sospechosa se escabulle entre los pinsapos y el Lince de la Noche se planta delante, pecho fuera, y suelta su frase de superhéroe:
—Se… se acabó la ronda…
Silencio. A lo lejos, un grillo contesta con un «¿cómo?».
La Gata Montesa, que es joven pero tiene las cosas clarísimas, lo mira desde la rama con cara de paciencia infinita.
—Lince, que eres un superhéroe, no un bibliotecario. Las frases se sueltan con pulmones.
—Es que me da corte, Gata…
—Pues mañana a las seis, academia de frases. Y trae la voz.
Así nació la academia más exigente de la sierra: el Lince lleva más rondas que nadie, pero quien manda es la Gata. Y hoy, por fin, la academia tiene aplicación.
Introducción
Hasta ahora el móvil había escuchado ruido (Chisme 05), hablado (Chisme 07), caminado con nosotros (Chisme 08) y leído papeles (Chisme 09). En este chisme el móvil hace algo distinto: nos mide la voz y nos dice si hemos llegado a la marca.
La idea es sencilla. Una barra se llena cuando hablas más fuerte. Si aguantas tres segundos por encima de la marca, la Gata te felicita y te da otra frase. Nada más. Sin teclear, sin menús, con botones enormes.
Las tres reglas de diseño son las mismas que en el Chisme 07, porque queremos enseñársela a personas con Parkinson (más abajo explicamos por qué):
- Nada de teclear. Todo se hace con la voz y con botones grandes.
- Una pantalla, una tarea. La frase, la barra y un botón enorme.
- Todo lo que dice la Gata, también escrito en grande, por si no se oye bien.
⚠️ Esto es una prueba técnica, no un tratamiento
No sabemos todavía si esta herramienta le sirve a alguien. Lo que queremos comprobar es si técnicamente funciona: si la barra reacciona bien, si tres segundos es un buen reto, si los botones son cómodos. La visita servirá para aprender qué hay que cambiar, no para demostrar nada.
Y una diferencia importante con el Chisme 07: aquí la voz no sale del móvil. Se analiza en el propio teléfono, no se graba y no se envía a ningún sitio. Lo único que viaja a Internet es una palabra («trabalenguas», «héroe» o «monte») cuando se le pide una frase nueva a la Gata.
Pruébala aquí (ábrela en su propia dirección, en el móvil): https://montesteam.org/app/chisme10voz.html
¿Por qué una app para medir la voz?
Mucha gente con enfermedad de Parkinson desarrolla hipofonía: la voz se va quedando baja, monótona y cuesta que los demás la oigan. Un detalle curioso que describen los autores del programa LSVT LOUD es que la persona suele sentir su voz como normal cuando en realidad está baja, y al pedirle que hable más fuerte le parece que está gritando.
Por eso la terapia de intensidad vocal entrena justo eso: recalibrar la sensación. Hablar «fuerte» hasta que lo fuerte vuelva a parecer normal.
Dos aclaraciones que importan:
- LSVT LOUD es un programa con nombre propio, intensivo y dirigido por profesionales formados en él. Nuestra app no es eso ni lo sustituye. Es una práctica técnica inspirada en la idea de fondo: dar al oído algo visible que le ayude a ver cuánto se está proyectando.
- La evidencia es buena para LSVT LOUD, pero limitada en general. Una revisión Cochrane concluyó que no había pruebas suficientes para comparar unas terapias del habla con otras. Lo decimos porque en ciencia conviene ser honestos con lo que se sabe y con lo que no.
Cómo funciona por dentro
El recorrido es cortito y no sale del móvil:
Micrófono → Web Audio (AnalyserNode) → nivel en decibelios → barra en pantalla
- El navegador abre el micrófono con
getUserMedia. - El
AnalyserNodenos da, muchas veces por segundo, una ristra de valores con la forma de la onda sonora. - Calculamos su volumen medio (raíz cuadrática media, RMS) y lo pasamos a decibelios.
- Suavizamos un poco el valor para que la barra no salte nerviosa.
- Si el nivel supera la marca, empieza a contar el tiempo. Si llega a tres segundos, ¡logro!
Tres decisiones de ingeniería
1. Medimos relativo, no absoluto. Los decibelios de la app no son los de un sonómetro (dB SPL): son «dB respecto al máximo digital del micrófono», y cada móvil tiene un micrófono distinto. Un número suelto no significaría nada. Por eso primero pulsamos «Medir mi voz»: leemos una frase con la voz de siempre durante cinco segundos, y la marca se coloca por encima de esa voz (por defecto, +6 dB). Lo que se entrena es la mejora sobre la propia voz de cada persona.
2. Apagamos los «arreglos» del navegador. Los navegadores, por defecto, cancelan eco, quitan ruido y suben el volumen solos (autoGainControl). Genial para una videollamada, fatal para medir: si la persona habla bajo, el navegador lo sube y la barra no se entera. Por eso pedimos el micrófono con esos tres ajustes en false. Algunos móviles pueden ignorarlos, así que conviene probar en Chrome.
3. La Gata no debe oírse a sí misma. Cuando la Gata felicita en voz alta, el micrófono oiría su voz y contaría un logro falso. Mientras speechSynthesis habla, el contador se queda a cero, y después hay una pausa de unos segundos.
Pensada para manos y cuerpos que tiemblan
- Botones grandes y con mucho espacio entre ellos.
- Freno anti doble toque: si se pulsa dos veces seguidas por un temblor, la segunda pulsación se ignora.
- Un pequeño bajón no cuenta como fallo: el contador solo se reinicia si el volumen cae más de 0,7 segundos.
- Si la voz sube demasiado, la Gata avisa: «Con firmeza, pero sin forzar la garganta». Fuerte no es lo mismo que forzado.
- La meta se puede subir y bajar con los botones + y − (de 3 a 12 dB). Quien decide la meta es el logopeda o el fisioterapeuta, no la app.
Las frases de la Gata
Las frases salen de dos sitios:
- Un banco local de diez frases, escritas dentro de la página: cuatro trabalenguas y seis frases del Lince y la Gata. Funciona sin Internet y sin gastar nada de cuota.
- «Frase nueva de la Gata»: la página le pide una frase a un Worker de Cloudflare, que se la pide a Gemini. Si falla (sin cobertura, sin cuota, Gemini saturado), la página no se rompe: saca una del banco local y la Gata dice que se le ha acabado la tinta.
Esto es una buena lección de programación: diseñar qué pasa cuando algo falla. La app no depende de la IA; la IA es un extra.
Un prompt por capas (otra vez)
Como en el Chisme 07, el prompt del Worker tiene dos capas:
- Capa base (nadie la toca): quién es la Gata, que la frase tiene de 8 a 16 palabras, que se va a leer en voz alta (sin cifras, siglas ni emojis), que es original (no copia frases de películas) y que nunca menciona enfermedades ni la voz de nadie.
- Capa de tema: la página solo manda una palabra:
trabalenguas,heroeomonte. El texto de las instrucciones vive en el Worker. Además, el Worker añade una «chispa» al azar (un buitre, la luna, un pinsapo…) para que dos frases seguidas del mismo tema no salgan iguales.
Un detalle nada casual: pedimos frases originales y no citas de películas famosas. Los oneliners de las pelis tienen autor y derechos; inventar los nuestros es más divertido y más limpio.
Para el aula
- Calibra con un sonómetro de verdad. Usa una app de sonómetro o el sensor de la Misión 12 para comprobar cuántos dB reales son «+6 dB» en la app. ¿Coinciden en todos los móviles de la clase? (Spoiler: no.) Es la mejor forma de entender por qué medimos relativo.
- El concurso de oneliners. Cada grupo escribe tres frases de héroe originales y las prueba con la app. Regla: nada de copiar películas.
- Rompe el Worker. Apaga el Internet del móvil y pulsa «Frase nueva de la Gata». ¿Qué pasa? ¿Por qué no se rompe?
- Inventa un tema nuevo. Añade
cocinaomaraTEMASen el Worker y aTEMASen la página. - Diseña la prueba. ¿Qué preguntarías a una persona usuaria para saber si la app es cómoda? Redactad cinco preguntas (más abajo tienes las nuestras).
Del aula a la calle: Aprendizaje-Servicio
Este chisme no es solo un ejercicio técnico. Nos gustaría que fuera la primera piedra de un proyecto de Aprendizaje-Servicio: el alumnado aprende programación, audio e IA construyendo algo útil para alguien de su entorno, y ese alguien participa en el diseño.
La ruta que imaginamos:
- Aprender: cómo funciona el micrófono, los decibelios, los Workers, los prompts.
- Escuchar: hablar con profesionales (logopedia, fisioterapia) y con personas usuarias antes de seguir programando.
- Construir y probar: con acompañamiento de profesionales y con consentimiento.
- Mejorar: cambiar lo que no funcione, no lo que a nosotros nos parezca bonito.
- Devolver: contar qué hemos aprendido, con sus límites.
Queremos ofrecerla a la asociación Parkinson Sol Estepona para que nos digan qué sirve y qué sobra. Lo de ofrecerla es literal: no sabemos todavía si les interesará, y si no, también aprenderemos.
Lo que queremos aprender de las personas
Cuando la probemos con personas con Parkinson, no vamos a demostrar nada, vamos a preguntar:
- ¿Se lee bien la frase? ¿El tamaño de letra es suficiente?
- ¿Los botones se pulsan con comodidad? ¿Hay pulsaciones dobles por error?
- ¿La barra reacciona como esperas cuando hablas más fuerte?
- ¿Tres segundos es un reto razonable? ¿Demasiado fácil? ¿Demasiado cansado?
- ¿Las frases son divertidas o te parecen de niños? (Importante: no son una terapia infantil).
- ¿Cómo te has sentido al terminar? ¿Cansancio? ¿Molestias en la garganta?
Y una condición que no negociamos: la sesión la dirige o supervisa un profesional, en sesiones cortas, con la persona sentada y sin forzar. Si se cansa, se para.
Paso 1: crea un Worker
En Cloudflare: Workers y Pages → Crear → Crear Worker. Llámalo entrenador-voz (cada webapp tiene el suyo) y pulsa Desplegar.
Paso 2: guarda tu clave como secreto
Configuración → Variables y secretos → Añadir:
GEMINI_API_KEY: tu clave de Gemini.
Paso 3: el código del Worker
Sustituye todo lo que trae el editor por el código de la sección WORKER (al final de esta entrada). Antes de desplegar, revisa esta línea y pon tu dominio, con y sin www:
const ORIGENES = ['https://montesteam.org', 'https://www.montesteam.org'];
Copia la dirección del Worker, algo como https://entrenador-voz.tu-usuario.workers.dev.
Paso 4: la página
Copia el código de la sección HTML y cambia esta línea por la dirección de tu Worker:
const WORKER = 'https://entrenador-voz.TU-USUARIO.workers.dev';
Súbelo a tu carpeta app como chisme10voz.html y ábrelo directamente en su dirección, desde el móvil.
Paso 5: pruébalo
- Pulsa «Medir mi voz», permite el micrófono y lee la frase con tu voz de siempre (en un sitio tranquilo).
- Pulsa «Empezar a entrenar» y lee la frase más fuerte, como si hablaras con alguien al otro lado de la sala.
- Aguanta tres segundos por encima de la marca y escucha a la Gata.
- Pulsa «Frase nueva de la Gata» varias veces y mira cómo cambia el tema.
- Sube y baja la meta con + y −.
Si no has cambiado la dirección del Worker, el botón de la Gata no fallará: te dará una frase del banco local.
Si algo falla
- «No puedo usar el micrófono»: mira el candado junto a la dirección, y abre la página directamente, no dentro de otra (un
iframebloquea el micrófono). - La barra casi no se mueve: ¿está el micrófono tapado o con el volumen bajo? Acerca el móvil. Y recuerda: lo primero, lo más tonto (Chisme 07, lección 1).
- «No le he oído bien»: la medición necesita unos segundos de voz. Lee la frase entera.
- La Gata cuenta logros sola: puede que el móvil ignore
autoGainControl. Prueba en Chrome o baja el volumen de los altavoces. - «La Gata no ha podido inventar una»: revisa la dirección del Worker, el dominio permitido (CORS) o si se ha agotado la cuota gratuita del día.

Conclusión
Hemos construido un instrumento con un micrófono, una fórmula y una barra. Lo técnico es asequible: AnalyserNode, un poco de matemáticas de decibelios y un Worker de unas pocas líneas. Lo interesante, otra vez, está en las decisiones: medir relativo y no absoluto, apagar los filtros que estropean la medida, que la voz no salga del móvil, qué pasa si la IA falla y quién decide la meta (nunca la app).
El Lince todavía susurra algunas veces. Pero ya tiene entrenadora, y la entrenadora ya tiene una barra que se llena. Ahora toca lo más importante: escuchar a las personas que van a probarla.
⚠️ ANTES DE PUBLICAR NADA, LEE ESTO
No es un tratamiento ni un producto sanitario. Es una prueba técnica. No prometas beneficios que no hemos comprobado y no la presentes como sustituta de la logopedia o la fisioterapia.
La meta la decide un profesional. La app no sabe cuánta intensidad es adecuada para cada persona. No fuerces la garganta: si hay cansancio, ronquera o molestias, se para.
Una IA puede equivocarse. La Gata inventa frases y puede generar alguna rara. Revísalas antes de una sesión y, si ves algo inadecuado, usa el banco local.
La voz no sale del móvil, pero el permiso del micrófono sí es un permiso. Explica a las personas usuarias qué hace la app y pide consentimiento, a ellas y a su familia, antes de la prueba.
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 otra.
Sin código de acceso, el Worker está abierto a quien conozca su dirección. Limita el dominio permitido y vigila el consumo. La capa gratuita de Gemini tiene un límite diario bajo.
Si la usas con menores o 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.
Para saber más
- Ramig LO, Sapir S, Fox C, Countryman S. Changes in vocal loudness following intensive voice treatment (LSVT) in individuals with Parkinson’s disease: a comparison with untreated patients and normal age-matched controls. Movement Disorders, 2001.
- Fox CM, Ramig LO, Ciucci MR, Sapir S, McFarland DH, Farley BG. The science and practice of LSVT/LOUD: neural plasticity-principled approach to treating individuals with Parkinson disease and other neurological disorders. Seminars in Speech and Language, 2006.
- Herd CP et al. Speech and language therapy versus placebo or no intervention for speech problems in Parkinson’s disease. Cochrane Database of Systematic Reviews, 2012.
- MDN Web Docs:
AnalyserNodeyMediaDevices.getUserMedia().
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="#1d2f5a">
<title>Chisme 10 · El Entrenador de Voz</title>
<style>
:root{--bg:#eceff6;--card:#fbfcfe;--ink:#14203a;--mute:#51607c;--line:#ccd4e4;--pine:#1d2f5a;--ocre:#c77d0a;--ok:#1f6b3a;--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 var(--ocre)}
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}
#frase{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;font-size:1.65rem;line-height:1.35;margin:0 0 10px;min-height:3.6em}
.fila{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:0 0 14px}
.fila button,.aj button{min-height:68px;font-size:1.05rem;padding:6px;background:var(--card);color:var(--pine);border:2.5px solid var(--pine)}
#medidor{position:relative;height:70px;background:var(--card);border:3px solid var(--pine);border-radius:16px;overflow:hidden;margin:0 0 8px}
#barra{position:absolute;inset:0 auto 0 0;width:0;background:var(--ocre)}
#barra.ok{background:var(--ok)}
#meta{position:absolute;top:0;bottom:0;width:8px;margin-left:-4px;background:var(--pine);display:none}
#tiempo{height:14px;background:var(--line);border-radius:7px;overflow:hidden;margin:0 0 6px}
#prog{height:100%;width:0;background:var(--ok)}
.dat{display:flex;justify-content:space-between;font-size:1.05rem;color:var(--mute);margin:0 0 14px}
.dat b{color:var(--ink);font-size:1.9rem}
#go{width:100%;min-height:24vh;font-size:2.1rem;padding:16px;margin:0 0 14px}
#go[data-e="on"]{background:var(--bad)}
#texto{min-height:4.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)}
.aj{display:grid;grid-template-columns:2fr 1fr 1fr;gap:10px}
small{display:block;color:var(--mute);font-size:.8rem;margin-top:12px}
</style>
</head>
<body>
<header><h1>El Entrenador de Voz</h1></header>
<main>
<p class="aviso"><strong>Prueba técnica, no un tratamiento.</strong> Su voz se analiza en el propio móvil: el sonido no se graba ni sale de aquí. No fuerce la garganta; si se cansa, pare. La meta de volumen la decide su logopeda o fisioterapeuta.</p>
<div id="frase" aria-live="polite"></div>
<div class="fila">
<button id="otra" type="button">Otra frase</button>
<button id="ia" type="button">Frase nueva de la Gata</button>
</div>
<div id="medidor"><div id="barra"></div><div id="meta"></div></div>
<div id="tiempo"><div id="prog"></div></div>
<div class="dat"><span>Aguante: <b id="seg">0</b> de 3 segundos</span><span>Logros: <b id="rondas">0</b></span></div>
<button id="go" type="button" data-e="off">Empezar a entrenar</button>
<div id="texto" aria-live="polite">Primero pulse «Medir mi voz» y lea la frase con su voz de siempre.</div>
<div class="aj">
<button id="medir" type="button">Medir mi voz (5 segundos)</button>
<button id="menos" type="button" aria-label="Meta más baja">−</button>
<button id="mas" type="button" aria-label="Meta más alta">+</button>
</div>
<small>Meta: <span id="plus">+6 dB</span> sobre su voz de siempre. Responde una IA que puede equivocarse; no sustituye a un profesional.</small>
</main>
<script>
const WORKER = 'https://entrenador-voz.TU-USUARIO.workers.dev'; // ← cambia por la tuya
const FRASES = [
'Tres tristes tigres tragaban trigo en un trigal.',
'Pablito clavó un clavito en la calva de un calvito.',
'Erre con erre cigarro, erre con erre barril.',
'Compré pocas copas, pocas copas compré.',
'Que nadie duerma: el Lince de la Noche ya está aquí.',
'Sierra Bermeja, no te escondas, que te vigilo.',
'La noche es larga, pero mi voz llega más lejos.',
'Yo no grito, yo proyecto, y la sierra me escucha.',
'Con la Gata a mi lado, no hay sombra que me asuste.',
'Hoy el monte me oye hasta la última palabra.'
];
const FELIC = ['¡Así se habla! Voz de Lince.', 'Eso ha llegado hasta la sierra.', '¡Muy bien! Se ha oído clarito.', 'Ese es el Lince de la Noche. ¡Otra!'];
const TEMAS = ['trabalenguas', 'heroe', 'monte'];
const HOLD = 3, $ = id => document.getElementById(id);
let modo = 'idle', base = null, plus = 6, sm = -90, mu = [], hold = 0, bajo = 0, t0 = 0, tp = 0, pausa = 0, avisoT = 0, rondas = 0, ti = 0;
let ac, an, buf, st, raf, bloq = 0;
function msg(t, e) { $('texto').textContent = t; $('texto').className = e ? 'err' : ''; }
function decir(t) {
if (!('speechSynthesis' in window)) 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 nueva(t) { $('frase').textContent = t || FRASES[Math.floor(Math.random() * FRASES.length)]; }
/* ---------- Micrófono: sin filtros del navegador, que "arreglan" el volumen y estropean la medida ---------- */
async function abrir() {
ac = new (window.AudioContext || window.webkitAudioContext)();
st = await navigator.mediaDevices.getUserMedia({audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}});
await ac.resume();
an = ac.createAnalyser(); an.fftSize = 2048; buf = new Float32Array(an.fftSize);
ac.createMediaStreamSource(st).connect(an);
}
function cerrar() {
cancelAnimationFrame(raf);
if (st) st.getTracks().forEach(t => t.stop());
if (ac) ac.close();
ac = an = st = null;
}
function nivel() { // dB respecto al máximo digital (siempre negativos): más cerca de 0 = más fuerte
an.getFloatTimeDomainData(buf);
let s = 0; for (const v of buf) s += v * v;
return 20 * Math.log10(Math.sqrt(s / buf.length) + 1e-9);
}
/* ---------- Dibujo ---------- */
function pintar() {
const lo = base === null ? -70 : base - 12, hi = base === null ? -10 : base + plus + 8;
const p = x => Math.max(0, Math.min(100, (x - lo) / (hi - lo) * 100));
const ok = base !== null && sm >= base + plus;
$('barra').style.width = p(sm) + '%'; $('barra').className = ok ? 'ok' : '';
$('meta').style.display = base === null ? 'none' : 'block'; if (base !== null) $('meta').style.left = p(base + plus) + '%';
$('seg').textContent = hold.toFixed(1).replace('.', ',');
$('prog').style.width = Math.min(100, hold / HOLD * 100) + '%';
}
/* ---------- Bucle: medir y entrenar ---------- */
function bucle() {
const now = performance.now(), dt = (now - tp) / 1000; tp = now;
const n = nivel(), habla = 'speechSynthesis' in window && speechSynthesis.speaking;
sm += .25 * (n - sm);
if (modo === 'medir') {
if (n > -55 && !habla) mu.push(n);
if (now - t0 > 5000) return finMedir();
} else if (modo === 'entrenar') {
if (habla || now < pausa) hold = 0; // la Gata no debe oírse a sí misma
else if (sm >= base + plus) { hold += dt; bajo = 0; if (hold >= HOLD) logrado(now); }
else { bajo += dt; if (bajo > .7) hold = 0; } // un pequeño bajón no cuenta como fallo
if (sm > base + plus + 10 && now - avisoT > 6000) { avisoT = now; msg('Con firmeza, pero sin forzar la garganta.'); }
}
pintar(); raf = requestAnimationFrame(bucle);
}
function logrado(now) {
hold = 0; rondas++; $('rondas').textContent = rondas; pausa = now + 3500;
const f = FELIC[Math.floor(Math.random() * FELIC.length)]; msg(f); decir(f); nueva();
}
function finMedir() {
const s = mu.sort((a, b) => a - b); mu = [];
parar();
if (s.length < 40) { msg('No le he oído bien. Acerque el móvil y repita.', true); return; }
base = s[Math.floor(s.length * .8)];
const f = 'Listo. Ahora diga la frase más fuerte, como si hablara con alguien al otro lado de la sala.';
msg(f); decir(f); pintar();
}
async function arrancar(m) {
try { await abrir(); }
catch (e) { cerrar(); msg('No puedo usar el micrófono. Permítalo con el candado junto a la dirección, y abra la página directamente, no dentro de otra.', true); return false; }
tp = performance.now(); sm = -90; hold = 0; bajo = 0; modo = m; raf = requestAnimationFrame(bucle); return true;
}
function parar() {
modo = 'idle'; cerrar(); hold = 0;
$('go').dataset.e = 'off'; $('go').textContent = 'Empezar a entrenar'; pintar();
}
/* ---------- Frase nueva con Gemini (opcional; si falla, banco local) ---------- */
async function gata() {
msg('La Gata está pensando una frase…');
try {
const r = await fetch(WORKER, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({tema: TEMAS[ti++ % 3]})});
const d = await r.json();
if (!r.ok || d.error || !d.texto) throw 0;
nueva(d.texto); msg('Frase nueva de la Gata. Léala con voz firme.');
} catch (_) { nueva(); msg('La Gata no ha podido inventar una. Le doy una de su libreta.'); }
}
/* ---------- Botones (con freno anti doble toque, por el temblor) ---------- */
const unico = fn => () => { const n = Date.now(); if (n - bloq < 500) return; bloq = n; fn(); };
$('go').onclick = unico(async () => {
if (modo === 'medir') return;
if (modo === 'entrenar') { parar(); msg('Buen trabajo. ' + rondas + ' logros.'); return; }
if (base === null) { msg('Primero pulse «Medir mi voz» y lea la frase con su voz de siempre.', true); return; }
if (await arrancar('entrenar')) { $('go').dataset.e = 'on'; $('go').textContent = 'Parar'; msg('Lea la frase con voz firme y aguante tres segundos por encima de la marca.'); }
});
$('medir').onclick = unico(async () => {
if (modo !== 'idle') return;
if (await arrancar('medir')) { t0 = performance.now(); mu = []; msg('Lea la frase con su voz de siempre, como en una conversación. Mido durante 5 segundos.'); }
});
const meta = d => unico(() => { plus = Math.max(3, Math.min(12, plus + d)); $('plus').textContent = '+' + plus + ' dB'; pintar(); });
$('mas').onclick = meta(1); $('menos').onclick = meta(-1);
$('otra').onclick = unico(() => nueva());
$('ia').onclick = unico(gata);
nueva(); pintar();
if (window.self !== window.top) msg('Esta página está incrustada en otra: el micrófono no funcionará. Ábrala en su propia dirección.', true);
else if (!window.isSecureContext) msg('La dirección no es https: el micrófono no funcionará.', true);
</script>
</body>
</html>
WORKER
// Worker "entrenador-voz": frases nuevas de la Gata Montesa para El Entrenador de Voz (Chisme 10).
// Secreto necesario: GEMINI_API_KEY
// La página solo envía {tema}. NO viaja ni audio ni nada de la persona.
// ⚠️ Cambia esto por el dominio donde vive la página (con y sin www)
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 tema puede cambiar
const BASE =
'Eres la Gata Montesa, la sidekick joven del Lince de la Noche, un superhéroe veterano e inseguro ' +
'de Sierra Bermeja (Estepona, Málaga). Tú mandas en la academia de frases: enseñas al Lince a decirlas con fuerza. ' +
'Tu trabajo es inventar UNA sola frase para que una persona la lea en voz alta y firme. Reglas: ' +
'entre 8 y 16 palabras; español de España; fácil de pronunciar; sin cifras, siglas, emojis, comillas, ' +
'paréntesis ni guiones; divertida y positiva, nunca humillante; ' +
'original: no copies frases de películas, canciones, libros ni de otros personajes; ' +
'nunca menciones enfermedades, salud, ni que la voz de alguien sea débil. ' +
'Responde SOLO con la frase, sin explicaciones.';
// CAPA 2: el tema (el navegador solo envía el nombre)
const TEMAS = {
trabalenguas:
'TEMA: un trabalenguas nuevo y corto, con sonidos repetidos (r, t, p, c, l), que dé risa decir con energía.',
heroe:
'TEMA: un remate de superhéroe, de los que se sueltan al final de una escena de acción, con chispa y humor, ' +
'como si la Gata le dictara al Lince cómo decirlo con aplomo.',
monte:
'TEMA: una frase sobre Sierra Bermeja (pinsapos, buitres, cabras montesas, ríos, noches estrelladas), ' +
'para decirla como si el monte entero la estuviera escuchando.',
};
// Para que dos peticiones seguidas del mismo tema no den lo mismo
const CHISPAS = ['un buitre', 'la luna', 'un pinsapo', 'una cabra montesa', 'el viento de levante',
'un río', 'las estrellas', 'un grillo', 'la lumbre', 'un camino', 'una lechuza', 'la niebla'];
const esperar = ms => new Promise(r => setTimeout(r, ms));
const elegir = lista => lista[Math.floor(Math.random() * lista.length)];
// Deja solo una frase limpia
function limpiar(t) {
const linea = String(t || '').split('\n').map(s => s.trim()).find(Boolean) || '';
return linea.replace(/\p{Extended_Pictographic}/gu, '').replace(/["“”«»*_#`>]/g, '').replace(/\s+/g, ' ').trim();
}
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 tema = TEMAS[c.tema] ? c.tema : 'monte';
const sistema = BASE + '\n\n' + TEMAS[tema];
const contents = [{ role: 'user', parts: [{ text: 'Dame la frase. Inspírate en: ' + elegir(CHISPAS) + '.' }] }];
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,
generationConfig: { temperature: 1.0 },
}),
});
if (r.ok) {
const d = await r.json();
const texto = limpiar(d?.candidates?.[0]?.content?.parts?.map(p => p.text).join('') || '');
if (texto.length < 15 || texto.length > 200) { ultimo = `${modelo} → frase fuera de rango`; continue; }
return new Response(JSON.stringify({ texto, modelo, tema }), { 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', 'No se ha podido obtener una frase. ' + ultimo);
},
};
Etiqueta:AnalyserNode, aprendizaje-servicio, Chismes del Lince, Cloudflare Workers, entrenador de voz, Gata Montesa, Gemini API, hipofonía, ia en el aula, ies monterroso, lince de la noche, medir decibelios con el móvil, prompt por capas, tecnología y Parkinson, terapia de intensidad vocal, web audio api



