Pantalla TV: el pito tapaba la primera palabra, y página para elegir voz

El llamado se oía "entrecortado" por dos motivos distintos.

playBeep() y speak() salían en el mismo instante, y el pito dura 0,65 s con
volumen 0,35: "Turno A-01" se pronunciaba por debajo del tono y desde lejos
parecía que el anuncio empezaba ya empezado. Se vuelve a esperar los 700 ms
que el código tenía antes de que se quitaran.

El otro motivo es la voz. getVozES() pide es-CO, que en Chrome es la de
Google: remota. Cada llamado la baja de internet, así que con wifi flojo se
corta o no suena. Las voces disponibles las decide el televisor, no el ERP,
así que en vez de adivinar se agrega /erp.php?m=turnero&v=voces: lista las
voces del equipo, marca cuáles son locales y cuáles se bajan de internet,
señala la que está sonando hoy y deja oír cada una con el texto real de un
llamado. Hay que abrirla en el televisor.

Queda descartado el eco entre pantallas: hay un solo televisor con sonido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-12 06:38:06 -05:00
co-authored by Claude Opus 5
parent cd087f65af
commit db133d9b5d
2 changed files with 149 additions and 6 deletions
+8 -6
View File
@@ -588,15 +588,17 @@ function anunciarTurno(codigo, destino, paciente) {
if (voz) utt.voice = voz;
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
// Con el sintetizador libre se habla de inmediato. Solo cuando hay algo en
// curso hace falta cancelar y esperar un instante: Chrome ignora un speak()
// encadenado a un cancel() en el mismo ciclo.
// 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;
// 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), 80);
} else {
window.speechSynthesis.speak(utt);
}
setTimeout(() => window.speechSynthesis.speak(utt), BEEP_MS);
return utt;
}
+141
View File
@@ -0,0 +1,141 @@
<?php
/**
* modules/turnero/views/voces.php
* Prueba de voces del televisor — /erp.php?m=turnero&v=voces
*
* Las voces disponibles no las decide el ERP sino el equipo: dependen del
* sistema operativo y del navegador del televisor. Por eso esta página hay que
* abrirla EN EL TELEVISOR, no en el computador de la oficina: cada equipo
* ofrece una lista distinta.
*
* Sirve para oír cada voz con el texto real de un llamado y elegir la que
* quede. Distingue las locales de las que se bajan de internet, que son las
* que se entrecortan cuando el wifi flaquea.
*/
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prueba de voces · Turnero</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #0f172a; color: #e2e8f0;
}
h1 { font-size: 1.5rem; margin: 0 0 4px; }
.sub { color: #94a3b8; font-size: .95rem; margin-bottom: 20px; }
.aviso {
background: #1e293b; border-left: 4px solid #38bdf8;
padding: 12px 16px; border-radius: 6px; margin-bottom: 22px;
font-size: .92rem; line-height: 1.5;
}
.fila {
display: flex; align-items: center; gap: 14px;
background: #1e293b; border-radius: 8px;
padding: 14px 16px; margin-bottom: 10px;
border: 1px solid #334155;
}
.fila.elegida { border-color: #38bdf8; background: #1e3a52; }
.nom { font-weight: 600; flex: 1; min-width: 0; }
.nom small { display: block; font-weight: 400; color: #94a3b8; font-size: .8rem; }
.tag {
font-size: .72rem; padding: 3px 9px; border-radius: 99px;
white-space: nowrap; font-weight: 600;
}
.local { background: #14532d; color: #86efac; }
.remota { background: #7c2d12; color: #fdba74; }
.actual { background: #0c4a6e; color: #7dd3fc; }
button {
background: #0284c7; color: #fff; border: 0;
padding: 10px 20px; border-radius: 6px; cursor: pointer;
font-size: .95rem; font-weight: 600; white-space: nowrap;
}
button:hover { background: #0369a1; }
#vacio { color: #94a3b8; padding: 20px 0; }
</style>
</head>
<body>
<h1>Prueba de voces</h1>
<div class="sub">Ábrala <strong>en el televisor</strong>: cada equipo ofrece voces distintas.</div>
<div class="aviso">
Las <span class="tag local">local</span> salen del propio equipo: nunca se cortan, aunque suenen
algo más robóticas.<br>
Las <span class="tag remota">de internet</span> suenan más naturales, pero se bajan de los
servidores de Google en cada llamado. Con wifi débil se entrecortan o no suenan.
<br><br>
Oiga las que aparezcan y dígame cuál prefiere.
</div>
<div id="lista"><div id="vacio">Cargando voces…</div></div>
<script>
// El mismo texto que dice la pantalla de turnos, para juzgarlas en condiciones
const TEXTO = 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2';
// Réplica de la preferencia que usa display_global.php hoy, para señalar
// cuál está sonando en este momento en el televisor.
function vozActual(voces) {
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
const v = voces.find(v => v.lang === lang);
if (v) return v;
}
return voces.find(v => v.lang.startsWith('es')) || null;
}
function probar(voz) {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(TEXTO);
u.voice = voz;
u.lang = voz.lang;
u.rate = 0.95;
u.pitch = 1.05;
window.speechSynthesis.speak(u);
}
function pintar() {
const todas = window.speechSynthesis.getVoices();
const es = todas.filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
const lista = document.getElementById('lista');
const actual = vozActual(es);
if (!es.length) {
lista.innerHTML = '<div id="vacio">Este equipo no tiene ninguna voz en español instalada. '
+ 'Habría que instalarle una desde la configuración del sistema, '
+ 'o dejar el llamado solo con el pito y el cartel.</div>';
return;
}
// Las locales primero: son las que interesan
es.sort((a, b) => (b.localService - a.localService) || a.name.localeCompare(b.name));
lista.innerHTML = '';
es.forEach((v, i) => {
const fila = document.createElement('div');
fila.className = 'fila' + (v === actual ? ' elegida' : '');
fila.innerHTML =
'<div class="nom">' + v.name + '<small>' + v.lang + '</small></div>'
+ (v.localService ? '<span class="tag local">local</span>'
: '<span class="tag remota">de internet</span>')
+ (v === actual ? '<span class="tag actual">la de ahora</span>' : '');
const b = document.createElement('button');
b.textContent = 'Oír';
b.onclick = () => probar(v);
fila.appendChild(b);
lista.appendChild(fila);
});
}
// getVoices() suele venir vacío en la primera llamada: el navegador las carga aparte
window.speechSynthesis.onvoiceschanged = pintar;
pintar();
setTimeout(pintar, 600);
</script>
</body>
</html>