diff --git a/modules/turnero/views/display_global.php b/modules/turnero/views/display_global.php index 1ea176a..2e9be3c 100644 --- a/modules/turnero/views/display_global.php +++ b/modules/turnero/views/display_global.php @@ -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); diff --git a/scripts/test_tv_voz.js b/scripts/test_tv_voz.js index a952606..b709cfb 100644 --- a/scripts/test_tv_voz.js +++ b/scripts/test_tv_voz.js @@ -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.