diff --git a/modules/turnero/views/display_global.php b/modules/turnero/views/display_global.php index 648b379..aaa65f7 100644 --- a/modules/turnero/views/display_global.php +++ b/modules/turnero/views/display_global.php @@ -629,32 +629,72 @@ if ('speechSynthesis' in window) { // 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. -function anunciarTurno(codigo, destino, paciente) { +// Espera antes de hablar: el pito dura 0,65 s y hablando encima tapaba +// "Turno X", que desde lejos sonaba como si el anuncio empezara ya empezado. +// A nivel de archivo porque showAnnouncement() también lo necesita. +const BEEP_MS = 700; + +/** + * Dice el llamado en voz alta. + * + * Avisa por `onFin` cuando terminó de hablar, o cuando quedó claro que no va a + * hablar. Lo segundo importa tanto como lo primero: el cartel se cierra con esa + * señal, y si nunca llegaba se quedaba pegado en pantalla. + * + * Si la voz elegida no arranca —pasa cuando el equipo la lista pero no la puede + * usar— se reintenta una vez con la del navegador, en vez de quedarse mudo. + * + * @return bool si se intentó hablar. Falso significa pantalla sin sonido. + */ +function anunciarTurno(codigo, destino, paciente, onFin) { playBeep(); - if (!sonidoActivo || !('speechSynthesis' in window)) return null; + if (!sonidoActivo || !('speechSynthesis' in window)) return false; + const letras = codigo.split('').join(' '); - const nomVoz = paciente ? paciente.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : null; + // \w no cuenta las letras acentuadas, así que trataba la tilde como + // separador: "maría" salía "MaríA" y la voz lo pronunciaba raro. + const nomVoz = paciente + ? paciente.toLowerCase().replace(/(^|\s)(\p{L})/gu, (_, sep, c) => sep + c.toUpperCase()) + : null; let texto = `Turno ${letras}`; if (nomVoz) texto += `, ${nomVoz}`; texto += `, pase a ${destino}`; - const utt = new SpeechSynthesisUtterance(texto); - utt.lang = 'es-CO'; - const voz = getVozES(); - if (voz) utt.voice = voz; - utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1; - // Se espera a que el pito termine (dura 0,65 s) antes de hablar. Hablando - // encima, el tono tapaba "Turno X" y desde lejos parecía que el anuncio - // empezaba ya empezado. - const BEEP_MS = 700; + let avisado = false; + const avisar = () => { if (!avisado) { avisado = true; onFin(); } }; - // Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un - // speak() encadenado a un cancel() en el mismo ciclo. - if (window.speechSynthesis.speaking || window.speechSynthesis.pending) { - window.speechSynthesis.cancel(); - } - setTimeout(() => window.speechSynthesis.speak(utt), BEEP_MS); - return utt; + const decir = (voz, esReintento) => { + const utt = new SpeechSynthesisUtterance(texto); + utt.lang = 'es-CO'; + if (voz) utt.voice = voz; + utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1; + + let arranco = false; + utt.addEventListener('start', () => { arranco = true; }); + utt.addEventListener('end', avisar); + utt.addEventListener('error', () => esReintento ? avisar() : decir(null, true)); + + // 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. + const margen = (esReintento ? 0 : BEEP_MS) + 1200; + setTimeout(() => { + if (arranco) return; + if (esReintento) { avisar(); return; } + window.speechSynthesis.cancel(); + decir(null, true); // segunda oportunidad, con la voz del navegador + }, margen); + + // Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un + // speak() encadenado a un cancel() en el mismo ciclo. + if (window.speechSynthesis.speaking || window.speechSynthesis.pending) { + window.speechSynthesis.cancel(); + } + setTimeout(() => window.speechSynthesis.speak(utt), esReintento ? 0 : BEEP_MS); + }; + + decir(getVozES(), false); + return true; } /* ── Fullscreen ── */ @@ -699,8 +739,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) { ov.classList.add('visible'); - const utt = anunciarTurno(codigo, destino, paciente); - bar.style.transition = 'none'; bar.style.width = '100%'; requestAnimationFrame(() => requestAnimationFrame(() => { @@ -725,11 +763,15 @@ function showAnnouncement({ codigo, destino, paciente, color }) { setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn))); }; - if (utt) { - utt.addEventListener('end', () => setTimeout(cerrarCuandoToque, 400)); - utt.addEventListener('error', cerrarCuandoToque); - // Red de seguridad: Chrome a veces no emite 'end' y el cartel quedaría fijo - setTimeout(dismiss, 25000); + // anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no + // va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las + // pantallas donde el navegador bloquea el audio. + const intentoHablar = anunciarTurno(codigo, destino, paciente, + () => setTimeout(cerrarCuandoToque, 400)); + + if (intentoHablar) { + // Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase + setTimeout(dismiss, 20000); } else { setTimeout(dismiss, MIN_VISIBLE); }