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
+54 -1
View File
@@ -33,7 +33,7 @@ ok('el despertador SSE está conectado (los llamados despiertan a la pestaña oc
js.includes("sse_turno.php?area=recepcion") && js.includes("addEventListener('cola_update'"));
// ── Armazón de simulación ───────────────────────────────────────────────
function escenario({ bloqueado = false, msArranque = 20, caida = false } = {}) {
function escenario({ bloqueado = false, msArranque = 20, caida = false, atascoUnaVez = false } = {}) {
let t = 0; const cola = [];
const st = (fn, ms) => cola.push({ t: t + ms, fn });
class Utt {
@@ -60,6 +60,12 @@ function escenario({ bloqueado = false, msArranque = 20, caida = false } = {}) {
resumeAntesDeSpeak = resumePendiente; resumePendiente = false;
totalSpeaks++;
if (bloqueado || caida) return; // descartado en silencio: ni pending queda
if (atascoUnaVez && totalSpeaks === 1) {
// El modo verificado en vivo (23/08): el motor JURA hablar
// (speaking=true) pero no suena y 'start' no llega jamás.
activa = u; synth.speaking = true;
return;
}
activa = u;
synth.pending = true;
st(() => { if (activa !== u) return; synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque);
@@ -180,6 +186,53 @@ function escenario({ bloqueado = false, msArranque = 20, caida = false } = {}) {
e.speaks() === speaksAntes + 1);
}
// ── 5d. MOTOR ATASCADO: speaking sin 'start' → romperlo y rehablar ──────
// Reproducido en vivo el 23/08: primer llamado tras el ocio, el motor queda
// en speaking=true sin audio. El vigilante debe detectarlo (una locución que
// de verdad suena dispara 'start' de inmediato), romperlo y rehablar — una
// sola vez, sin dobles.
{
const e = escenario({ atascoUnaVez: true });
let avisos = 0;
e.api.anunciarTurno('F01', 'Consultorio 1', null, () => { avisos++; });
e.correr();
ok('el atasco se detecta y el reintento habla: exactamente 2 speak',
e.speaks() === 2);
ok('el llamado termina avisando UNA vez (el reintento habló completo)',
avisos === 1);
ok('sin locuciones ancladas al final (sin fugas tras el atasco)',
e.api._uttAncladas.size === 0);
}
// ── 5e. Keepalive mudo: el motor recibe trabajo inaudible en el ocio ────
// pause/resume no mantiene despierto al motor (verificado en vivo); hablar a
// volumen cero sí. Este caso vigila que el keepalive exista y sea inaudible.
{
const intervalos = [];
let t = 0; const cola = [];
const st = (fn, ms) => cola.push({ t: t + ms, fn });
class Utt { constructor(x) { this.texto = x; this.l = {}; } addEventListener() {} }
let hablado = null;
const synth = { speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
getVoices: () => [], speak(u) { hablado = u; } };
new Function('window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
'localStorage', 'navigator', 'Blob', 'Date', 'document',
cuerpo + ';')(
{ speechSynthesis: synth }, st, (fn, ms) => intervalos.push({ fn, ms }), Utt,
{ getItem: () => '[]', setItem: () => {} }, { sendBeacon: () => {} },
function Blob() {},
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => 0 }),
{ addEventListener: () => {}, hidden: false });
const ka = intervalos.find(i => i.ms === 25000);
ok('existe el keepalive de 25 s', !!ka);
if (ka) ka.fn();
ok('en el ocio habla una letra a VOLUMEN CERO (inaudible, pero trabajo real)',
hablado !== null && hablado.volume === 0);
ok('con el motor ocupado el keepalive no interfiere', (() => {
hablado = null; synth.speaking = true; if (ka) ka.fn(); return hablado === null;
})());
}
// ── 6. El camino feliz: habla bien ──────────────────────────────────────
// Qué dice exactamente, y con qué voz. Un anuncio que "funciona" pero
// pronuncia mal el nombre o elige la voz equivocada también es un fallo.