DONE: add two voice fallback where there is no malay voicepack, update to display horizontally for number in queue in tv display
Build Docker Image / build-admin (push) Successful in 15s
Build Docker Image / build-teller (push) Successful in 10s
Build Docker Image / build-customer (push) Successful in 6s
Build Docker Image / build-backend (push) Successful in 1m36s

This commit is contained in:
ISMAIL MASSERAN
2026-09-23 12:06:48 +08:00
parent a4fb7b4d8b
commit 800bdafb1d
4 changed files with 96 additions and 23 deletions
@@ -280,31 +280,36 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.45em; flex-wrap: nowrap;
align-items: stretch;
gap: 0.55em;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: hidden;
} }
.branch-display__waiting-item { .branch-display__waiting-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 0.75em; flex: 1 1 0;
padding: 0.55em 0.85em; min-width: 0;
padding: 0.55em 0.45em;
border-radius: 0.4em; border-radius: 0.4em;
background: var(--bd-surface); background: var(--bd-surface);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
border-left: 0.2em solid var(--bd-green); border-bottom: 0.2em solid var(--bd-green);
flex-shrink: 0;
} }
.branch-display__waiting-number { .branch-display__waiting-number {
font-size: clamp(1.35em, 2.4vw, 2.5em); font-size: clamp(1.2em, 2vw, 2.25em);
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--bd-accent); color: var(--bd-accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.branch-display__waiting-service { .branch-display__waiting-service {
@@ -214,6 +214,8 @@ export default function BranchDisplayPage() {
[tickets] [tickets]
); );
const waitingPreview = useMemo(() => waiting.slice(0, 5), [waiting]);
const sortedStations = useMemo( const sortedStations = useMemo(
() => [...stations].sort((a, b) => String(a.name).localeCompare(String(b.name))), () => [...stations].sort((a, b) => String(a.name).localeCompare(String(b.name))),
[stations] [stations]
@@ -285,7 +287,7 @@ export default function BranchDisplayPage() {
<p className="branch-display__empty">Tiada nombor dalam giliran.</p> <p className="branch-display__empty">Tiada nombor dalam giliran.</p>
) : ( ) : (
<ul className="branch-display__waiting-list"> <ul className="branch-display__waiting-list">
{waiting.map((ticket) => ( {waitingPreview.map((ticket) => (
<li key={ticket.id} className="branch-display__waiting-item"> <li key={ticket.id} className="branch-display__waiting-item">
<span className="branch-display__waiting-number"> <span className="branch-display__waiting-number">
{ticket.number} {ticket.number}
@@ -122,7 +122,8 @@ export function getNewServingCalls(previous, nextOrTickets) {
calls.push({ calls.push({
stationId, stationId,
ticketNumber: number, ticketNumber: number,
stationName: ticket.station.name ?? `Stesen ${stationId}`, // Leave unset when missing so speech can pick Stesen/Station by locale.
stationName: ticket.station.name ?? undefined,
}); });
} }
+77 -12
View File
@@ -1,4 +1,5 @@
const MALAY_LANG = 'ms-MY'; const MALAY_LANG = 'ms-MY';
const ENGLISH_LANG = 'en-US';
const MALAY_DIGITS = [ const MALAY_DIGITS = [
'kosong', 'kosong',
@@ -13,6 +14,19 @@ const MALAY_DIGITS = [
'sembilan', 'sembilan',
]; ];
const ENGLISH_DIGITS = [
'zero',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
];
let speechUnlocked = false; let speechUnlocked = false;
let speechQueue = []; let speechQueue = [];
let speaking = false; let speaking = false;
@@ -35,27 +49,68 @@ function pickMalayVoice() {
); );
} }
function pickEnglishVoice() {
const voices = loadVoices();
return (
voices.find((voice) => voice.lang === ENGLISH_LANG) ||
voices.find((voice) => voice.lang.startsWith('en')) ||
null
);
}
/** /**
* Spell ticket numbers digit-by-digit in Malay for clearer announcements. * Prefer Malay when a Malay voice pack is installed; otherwise fall back to English
* (common on Windows branches without the Malay language pack).
*/ */
export function formatTicketNumberForSpeech(ticketNumber) { function resolveSpeechLocale() {
const malayVoice = pickMalayVoice();
if (malayVoice) {
return { lang: MALAY_LANG, voice: malayVoice, locale: 'ms' };
}
const englishVoice = pickEnglishVoice();
return {
lang: ENGLISH_LANG,
voice: englishVoice,
locale: 'en',
};
}
function formatTicketNumberForSpeech(ticketNumber, digits) {
return String(ticketNumber) return String(ticketNumber)
.trim() .trim()
.split('') .split('')
.map((character) => { .map((character) => {
if (/\d/.test(character)) { if (/\d/.test(character)) {
return MALAY_DIGITS[Number(character)]; return digits[Number(character)];
} }
return character; return character;
}) })
.join(' '); .join(' ');
} }
function resolveStationName(stationName, stationId, locale) {
if (stationName) return stationName;
return locale === 'ms' ? `Stesen ${stationId}` : `Station ${stationId}`;
}
export function formatMalayAnnouncement(ticketNumber, stationName) { export function formatMalayAnnouncement(ticketNumber, stationName) {
const spokenNumber = formatTicketNumberForSpeech(ticketNumber); const spokenNumber = formatTicketNumberForSpeech(ticketNumber, MALAY_DIGITS);
return `Nombor giliran ${spokenNumber}, sila ke ${stationName}.`; return `Nombor giliran ${spokenNumber}, sila ke ${stationName}.`;
} }
export function formatEnglishAnnouncement(ticketNumber, stationName) {
const spokenNumber = formatTicketNumberForSpeech(ticketNumber, ENGLISH_DIGITS);
return `Queue number ${spokenNumber}, please proceed to ${stationName}.`;
}
function formatAnnouncement(ticketNumber, stationName, locale) {
if (locale === 'ms') {
return formatMalayAnnouncement(ticketNumber, stationName);
}
return formatEnglishAnnouncement(ticketNumber, stationName);
}
/** /**
* Call once after a user gesture so later speech is allowed on strict browsers. * Call once after a user gesture so later speech is allowed on strict browsers.
*/ */
@@ -64,9 +119,10 @@ export function unlockQueueSpeech() {
try { try {
loadVoices(); loadVoices();
const { lang } = resolveSpeechLocale();
const utterance = new SpeechSynthesisUtterance(''); const utterance = new SpeechSynthesisUtterance('');
utterance.volume = 0; utterance.volume = 0;
utterance.lang = MALAY_LANG; utterance.lang = lang;
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
} catch { } catch {
// Ignore unlock failures; real announcements will still be attempted. // Ignore unlock failures; real announcements will still be attempted.
@@ -79,11 +135,10 @@ function processSpeechQueue() {
if (speaking || speechQueue.length === 0 || !speechSynthesisAvailable()) return; if (speaking || speechQueue.length === 0 || !speechSynthesisAvailable()) return;
speaking = true; speaking = true;
const text = speechQueue.shift(); const { text, lang, voice } = speechQueue.shift();
const utterance = new SpeechSynthesisUtterance(text); const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = MALAY_LANG; utterance.lang = lang;
const voice = pickMalayVoice();
if (voice) { if (voice) {
utterance.voice = voice; utterance.voice = voice;
} }
@@ -105,18 +160,28 @@ function processSpeechQueue() {
} }
/** /**
* Queue Malay voice announcements for one or more station calls. * Queue voice announcements for one or more station calls.
* @param {{ ticketNumber: string, stationName: string }[]} calls * Uses Malay when available, otherwise English.
* @param {{ ticketNumber: string, stationName?: string, stationId?: string }[]} calls
*/ */
export function announceQueueCalls(calls) { export function announceQueueCalls(calls) {
if (!calls?.length || !speechSynthesisAvailable()) return; if (!calls?.length || !speechSynthesisAvailable()) return;
unlockQueueSpeech(); unlockQueueSpeech();
const { lang, voice, locale } = resolveSpeechLocale();
for (const call of calls) { for (const call of calls) {
speechQueue.push( const stationName = resolveStationName(
formatMalayAnnouncement(call.ticketNumber, call.stationName) call.stationName,
call.stationId,
locale
); );
speechQueue.push({
text: formatAnnouncement(call.ticketNumber, stationName, locale),
lang,
voice,
});
} }
processSpeechQueue(); processSpeechQueue();