Voz del televisor: cazado en vivo el atasco del motor, y cómo impedirlo

Emulación en vivo con el usuario escuchando (23/08, 12:36-12:44): rellamados
reales a los dos turnos en recepción, instrumentando la pestaña. El fallo
apareció a la primera y quedó fotografiado:

    speaking: true          ← el motor JURA estar hablando
    'start' nunca disparó   ← pero no suena, y no hay error alguno
    usuario: «salió pero no habló»

Es el motor de voz durmiéndose tras el ocio: el primer speak() de ahí en
adelante queda atascado — finge hablar sin producir audio. El vigilante lo
toleraba porque speaking=true parece legítimo, y el keepalive pause/resume ni
lo prevenía ni podía correr (solo actúa con el motor en reposo). Por eso «el
primero no habla y el segundo sí»: el cancel() del segundo desatascaba.

Dos piezas, ambas validadas en vivo antes de entrar al repositorio:

1. PREVENIR: el keepalive ahora habla de verdad — una letra a volumen cero
   cada 25 s con el motor ocioso. Trabajo inaudible que lo mantiene despierto.
   En la ronda de validación, el llamado tras 30 s de ocio (el caso que
   siempre fallaba) habló completo a la primera, y los cuatro de la ronda
   también, sin un solo reintento.

2. CURAR: el vigilante distingue ahora tres estados en vez de dos:
   pending sin speaking = voz remota descargando (prórroga de 6 s);
   speaking sin 'start' = ATASCO (una locución que de verdad suena dispara
   'start' de inmediato): se invalida la generación, cancel(), y se rehabla
   tras 500 ms de respiro — rehablar de inmediato se volvía a atascar.
   La bitácora lo registra como sin_start con atasco:1.

La primera versión del desatascador (probada en vivo) reintentaba por partida
doble: el cancel() dispara 'error' sobre la misma locución y ese handler
también reintentaba. Se invalida la generación ANTES de cancelar.

Suite: 24 verificaciones, incluyendo el atasco simulado (exactamente 2 speak,
un aviso, sin fugas) y el keepalive (existe, es inaudible, no interfiere).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-23 12:46:48 -05:00
co-authored by Claude Fable 5
parent c9b3ebc179
commit 1ba6805799
2 changed files with 98 additions and 21 deletions
+44 -20
View File
@@ -633,15 +633,21 @@ function getVozES() {
}
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
// Keepalive: Chrome suspende speechSynthesis tras un rato sin uso.
// Solo debe correr con el sintetizador en reposo: pause() a mitad de una
// frase la corta, y como el anuncio dura más de 10 segundos, antes lo
// troceaba siempre.
// Keepalive con TRABAJO MUDO. El motor de voz (comprobado en macOS, y
// reportado también en Windows) se duerme tras un rato de ocio, y el primer
// speak() de ahí en adelante se ATASCA: speaking=true, cero audio, ni
// 'start' ni 'error' jamás. pause()/resume() no lo mantiene despierto —
// se verificó en vivo el 23/08: el primer llamado tras el ocio salía solo
// con el pito. Lo único que lo mantiene despierto es hablar de verdad:
// una letra a volumen cero cada 25 s, inaudible, con el motor en reposo.
setInterval(() => {
if (!sonidoActivo) return;
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
window.speechSynthesis.pause();
const u = new SpeechSynthesisUtterance('a');
u.volume = 0; u.rate = 2;
window.speechSynthesis.resume();
}, 10000);
window.speechSynthesis.speak(u);
}, 25000);
}
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
@@ -762,29 +768,47 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
soltar();
if (!vigente()) return; // 'interrupted' por un cancel nuestro: no reintentar
vozLog('error', { cod: codigo, err: ev.error || '?', re: esReintento ? 1 : 0 });
esReintento ? avisar() : decir(true);
if (esReintento) { avisar(); return; }
// Invalidar ANTES de cancelar: el cancel dispara eventos sobre esta
// misma locución y sin esto el reintento salía por partida doble.
_vozGen++;
window.speechSynthesis.cancel();
setTimeout(() => decir(true), 500);
});
// Que speak() no lance nada no significa que vaya a sonar: si el
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
// nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre.
// Rendirse con esta locución: romper el atasco y pasar al reintento
// (o, si ya era el reintento, declarar mudo el llamado y soltar el cartel).
const rendirse = () => {
soltar();
if (esReintento) {
vozLog('mudo', { cod: codigo });
avisar(); return;
}
// atasco=1: el motor JURA estar hablando (speaking) pero 'start'
// nunca llegó — el modo de fallo verificado en vivo el 23/08.
vozLog('sin_start', { cod: codigo,
atasco: window.speechSynthesis.speaking ? 1 : 0 });
_vozGen++; // los eventos del cancel no valen
window.speechSynthesis.cancel();
setTimeout(() => decir(true), 500); // respiro: rehablar YA se atasca
};
const margen = (esReintento ? 0 : BEEP_MS) + 2500;
setTimeout(() => {
if (!vigente()) { soltar(); return; }
if (arranco) return;
// Antes de darla por fallida hay que preguntarle al sintetizador:
// una voz remota tarda en arrancar porque se baja de internet, y
// cancelarla aquí era cortarle la frase y repetirla con otra voz.
// De ahí que a veces se oyera media frase y luego otra distinta.
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
soltar();
if (esReintento) {
vozLog('mudo', { cod: codigo }); // ni la voz del navegador arrancó
avisar(); return;
// pending sin speaking = voz remota descargando: una prórroga de 6 s
// y solo entonces se da por perdida. Cortarla de una fue el defecto
// del entrecortado original.
if (window.speechSynthesis.pending && !window.speechSynthesis.speaking) {
setTimeout(() => { if (!vigente() || arranco) return; rendirse(); }, 6000);
return;
}
vozLog('sin_start', { cod: codigo }); // la elegida nunca arrancó: reintento
window.speechSynthesis.cancel();
decir(true); // segunda oportunidad, con la voz del navegador
// speaking sin 'start' = ATASCO (finge hablar sin audio). Una locución
// que de verdad suena dispara 'start' de inmediato: romperlo.
rendirse();
}, margen);
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
@@ -807,7 +831,7 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
// sobre un motor sano no hace nada, así que se llama siempre.
window.speechSynthesis.resume();
window.speechSynthesis.speak(utt);
}, esReintento ? 0 : BEEP_MS);
}, esReintento ? 250 : BEEP_MS);
};
decir(false);