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:
co-authored by
Claude Fable 5
parent
c9b3ebc179
commit
1ba6805799
@@ -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);
|
||||
|
||||
+54
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user