Voz del televisor y demora de RIPS: análisis de fondo de los dos "a veces"

── Voz que a veces no suena o se corta ──

Dos causas nuevas, ambas intermitentes por naturaleza:

1. Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
   el recolector de basura puede llevársela A MITAD DE FRASE. El audio se corta
   en seco y 'end' no llega. Depende de cuándo pase el recolector, así que
   nunca se reproduce a voluntad. Cada locución queda anclada en un Set hasta
   terminar (_uttAncladas).

2. Tras un cancel(), en Windows y ChromeOS el motor puede quedar en pausa:
   speak() encola y jamás suena, sin error alguno. resume() antes de cada
   speak() lo destranca, y sobre un motor sano no hace nada.

Y porque lo intermitente no se depura mirando la pantalla, queda una bitácora:
cada intento de hablar registra voz elegida, si es local, cuánto tardó en
arrancar y cómo terminó — en localStorage.tvVozLog (últimos 200) y en la tabla
turnero_tv_log vía sendBeacon (endpoint log_tv.php, insert-only, eventos de
lista cerrada). La próxima vez que reporten "ayer a las 10 no sonó", se
consulta la tabla y se ve qué pasó exactamente.

── Servicios de RIPS que "se demoran" ──

Medido en datos, no en hipótesis: la facturación en el sistema del laboratorio
ocurre MIENTRAS la recepcionista atiende. La consulta a RIPS se hacía UNA sola
vez, al vincular al paciente — y en 7 días, 547 de 549 registros llegaron
DESPUÉS de esa única consulta. Nadie volvía a preguntar: eso es lo que se
percibía como lentitud de RIPS. (El scheduler en sí tarda segundos; de paso:
el reloj del equipo legado está ~1 hora atrasado, visible en hora_recepcion.)

Ahora la ficha sondea cada 10 s hasta 10 minutos y se detiene al encontrar,
al limpiar la ficha o al agotar los intentos. Si la recepcionista ya marcó
exámenes a mano, no se le pisan: se le ofrece el banner en vez de autocargar.

── Pruebas (22, todas contra el código real extraído de las vistas) ──

  scripts/test_tv_voz.js        9  anclaje GC, resume(), bloqueo, voz lenta, bitácora
  scripts/test_rips_sondeo.js   7  reintentos, paradas, tope, sin duplicar
  scripts/test_rips_ventana.php 6  ventana SQL en tabla TEMPORARY, sin tocar datos

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-23 11:11:40 -05:00
co-authored by Claude Fable 5
parent 8218daaff7
commit a28ae82402
7 changed files with 482 additions and 7 deletions
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* POST /modules/turnero/api/log_tv.php
* Recibe la bitácora de voz de la pantalla del televisor (via sendBeacon).
*
* SIN SESIÓN, a propósito: la pantalla es un kiosco público (display_global es
* ruta pública) y el envío es fire-and-forget. Por eso el endpoint es
* deliberadamente sordo y estrecho:
* - solo acepta los eventos de la lista, nada libre;
* - recorta el detalle a 500 caracteres;
* - no devuelve datos, solo un 204;
* - inserta y nada más: no lee, no borra, no actualiza.
*/
require_once __DIR__ . '/../../../config/config.php';
http_response_code(204);
header('Content-Type: text/plain');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') exit;
$raw = file_get_contents('php://input');
if ($raw === false || strlen($raw) > 2000) exit;
$d = json_decode($raw, true);
if (!is_array($d)) exit;
$evento = $d['e'] ?? '';
$PERMITIDOS = ['start', 'end', 'error', 'sin_start', 'mudo'];
if (!in_array($evento, $PERMITIDOS, true)) exit;
unset($d['e'], $d['t']); // el timestamp lo pone la base, no el cliente
try {
$pdo = Database::getInstance()->getConnection();
$stmt = $pdo->prepare(
"INSERT INTO turnero_tv_log (evento, detalle, ip, user_agent) VALUES (?, ?, ?, ?)"
);
$stmt->execute([
$evento,
mb_substr(json_encode($d, JSON_UNESCAPED_UNICODE), 0, 500),
substr(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]), 0, 45),
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
]);
} catch (\Throwable $e) {
// La bitácora jamás debe afectar a la pantalla
error_log('[log_tv] ' . $e->getMessage());
}
+60 -4
View File
@@ -651,6 +651,35 @@ if ('speechSynthesis' in window) {
// A nivel de archivo porque showAnnouncement() también lo necesita.
const BEEP_MS = 700;
// ── Anclaje contra el recolector de basura ──
// Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
// el recolector puede llevársela A MITAD DE FRASE. El audio se corta en seco y
// 'end' no llega nunca. Como depende de cuándo pase el recolector, ocurre "a
// veces" y nunca se reproduce a voluntad. Cada locución queda anclada aquí
// hasta que termina.
const _uttAncladas = new Set();
// ── Bitácora de la voz ──
// Los fallos intermitentes no se pueden depurar mirando la pantalla: hay que
// registrarlos cuando ocurren. Cada evento va a un anillo local (últimos 200,
// consultable con localStorage.tvVozLog en la consola del televisor) y al
// servidor con sendBeacon, que no bloquea ni exige respuesta.
function vozLog(evento, datos) {
const fila = Object.assign({ t: new Date().toISOString().slice(0, 19), e: evento }, datos || {});
try {
const ring = JSON.parse(localStorage.getItem('tvVozLog') || '[]');
ring.push(fila);
while (ring.length > 200) ring.shift();
localStorage.setItem('tvVozLog', JSON.stringify(ring));
} catch (_) {}
try {
navigator.sendBeacon(
'<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/log_tv.php',
new Blob([JSON.stringify(fila)], { type: 'application/json' })
);
} catch (_) {}
}
/**
* Dice el llamado en voz alta.
*
@@ -685,10 +714,28 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
utt.lang = 'es-CO';
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
_uttAncladas.add(utt);
const soltar = () => _uttAncladas.delete(utt);
const pedido = Date.now();
let arranco = false;
utt.addEventListener('start', () => { arranco = true; });
utt.addEventListener('end', avisar);
utt.addEventListener('error', () => esReintento ? avisar() : decir(true));
utt.addEventListener('start', () => {
arranco = true;
vozLog('start', { cod: codigo, ms: Date.now() - pedido,
voz: utt.voice ? utt.voice.name : '(navegador)',
local: utt.voice ? !!utt.voice.localService : null,
re: esReintento ? 1 : 0 });
});
utt.addEventListener('end', () => {
soltar();
vozLog('end', { cod: codigo, dur: Date.now() - pedido });
avisar();
});
utt.addEventListener('error', (ev) => {
soltar();
vozLog('error', { cod: codigo, err: ev.error || '?', re: esReintento ? 1 : 0 });
esReintento ? avisar() : decir(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
@@ -701,7 +748,12 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
// 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;
if (esReintento) { avisar(); return; }
soltar();
if (esReintento) {
vozLog('mudo', { cod: codigo }); // ni la voz del navegador arrancó
avisar(); return;
}
vozLog('sin_start', { cod: codigo }); // la elegida nunca arrancó: reintento
window.speechSynthesis.cancel();
decir(true); // segunda oportunidad, con la voz del navegador
}, margen);
@@ -721,6 +773,10 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
const voz = getVozES();
if (voz) utt.voice = voz;
}
// Tras un cancel(), en Windows y ChromeOS el motor puede quedar en
// pausa: speak() encola y jamás suena, sin error alguno. resume()
// sobre un motor sano no hace nada, así que se llama siempre.
window.speechSynthesis.resume();
window.speechSynthesis.speak(utt);
}, esReintento ? 0 : BEEP_MS);
};
+31 -3
View File
@@ -1553,7 +1553,7 @@ function abrirFicha(turno) {
.catch(() => {});
} else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) {
// El kiosko capturó una cédula como nombre — consultar RIPS de inmediato
consultarExamenesRips(turno.paciente_nombre.trim());
iniciarSondeoRips(turno.paciente_nombre.trim());
}
// Cargar consentimientos del desk actual para este turno
@@ -1673,7 +1673,7 @@ function seleccionarPaciente(pac) {
// Consultar exámenes recientes en RIPS (últimos 5 min)
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
if (cedula) consultarExamenesRips(cedula);
if (cedula) iniciarSondeoRips(cedula);
filtrarExamenesPorGenero();
}
@@ -1696,6 +1696,31 @@ function filtrarExamenesPorGenero() {
// ── Exámenes desde RIPS ───────────────────────────────────────
let _ripsData = null;
// ── Sondeo RIPS ──
// La facturación en el sistema del laboratorio ocurre MIENTRAS la
// recepcionista atiende, así que consultar una sola vez al vincular al
// paciente casi siempre llega temprano: en 7 días, 547 de 549 registros RIPS
// aparecieron DESPUÉS de esa única consulta, y nadie volvía a preguntar. Eso
// es lo que se percibía como "RIPS se demora". Se sondea cada 10 s hasta 10
// minutos, y se detiene al encontrar, al limpiar la ficha o al crear el turno.
let _ripsSondeoId = null;
let _ripsSondeoQuedan = 0;
function iniciarSondeoRips(cedula) {
detenerSondeoRips();
if (!cedula) return;
_ripsSondeoQuedan = 60; // 60 intentos × 10 s = 10 min
consultarExamenesRips(cedula); // el primero, de inmediato
_ripsSondeoId = setInterval(() => {
if (--_ripsSondeoQuedan <= 0 || _ripsData) { detenerSondeoRips(); return; }
consultarExamenesRips(cedula);
}, 10000);
}
function detenerSondeoRips() {
if (_ripsSondeoId) { clearInterval(_ripsSondeoId); _ripsSondeoId = null; }
}
async function consultarExamenesRips(cedula) {
_ripsData = null;
document.getElementById('banner-rips').classList.add('d-none');
@@ -1715,7 +1740,9 @@ async function consultarExamenesRips(cedula) {
}
// Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan
// automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación.
if (d.fuente === 'cache') {
detenerSondeoRips();
const yaHayManuales = examTS && examTS.items && examTS.items.length > 0;
if (d.fuente === 'cache' && !yaHayManuales) {
await cargarExamenesRips();
} else {
document.getElementById('banner-rips-txt').textContent =
@@ -1760,6 +1787,7 @@ async function cargarExamenesRips() {
}
function descartarRips() {
detenerSondeoRips();
_ripsData = null;
document.getElementById('banner-rips').classList.add('d-none');
}