/** * Vietnamese number/date/text normalization for DISPLAY only. * * IMPORTANT: This only modifies the SUBTITLES and CHAT display text. * It does NOT affect the TTS audio output path — audio comes directly * from the S2S backend as PCM16, unaffected by this text processing. * * "Mất âm thanh TTS" root cause analysis: * The problem was NOT this file — the Vietnamese formatting examples in * app.js instructions were too verbose, causing session.update WebSocket * messages to be rejected or delayed. Fixed by simplifying instructions. * * Normalize cautiously: only run on complete (non-partial) transcripts, * never on every delta event. Heavy regex on 50+ deltas/second lags the * event loop and can starve the audio buffer processing. */ // ── Vietnamese number words ─────────────────────────────────────────────── const DIGITS = [ "không", "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín", ]; function numberToVietnamese(n) { if (n === 0) return "không"; if (n < 0) return "âm " + numberToVietnamese(-n); const units = ["", "nghìn", "triệu", "tỷ"]; const groups = []; let temp = n; while (temp > 0) { groups.push(temp % 1000); temp = Math.floor(temp / 1000); } if (groups.length === 0) groups.push(0); const readGroup = (g) => { if (g === 0) return ""; const h = Math.floor(g / 100); const r = g % 100; let s = ""; if (h > 0) s += DIGITS[h] + " trăm "; else if (groups.length > 1) s += "không trăm "; if (r === 0) return s.trim(); if (r < 10) { s += (h > 0 && r === 5) ? "lẻ năm" : "lẻ " + DIGITS[r]; } else if (r < 20) { s += "mười" + (r === 10 ? "" : (r === 15 ? " lăm" : " " + DIGITS[r % 10])); } else { const t = Math.floor(r / 10); const o = r % 10; s += ["", "", "hai mươi", "ba mươi", "bốn mươi", "năm mươi", "sáu mươi", "bảy mươi", "tám mươi", "chín mươi"][t]; if (o === 1) s += " mốt"; else if (o === 5) s += " lăm"; else if (o > 0) s += " " + DIGITS[o]; } return s.replace(/\s+/g, " ").trim(); }; let result = ""; for (let i = groups.length - 1; i >= 0; i--) { const g = groups[i]; if (g === 0) continue; result += readGroup(g); if (i > 0) result += " " + units[i] + " "; } return result.replace(/\s+/g, " ").trim(); } /** * Normalize Vietnamese text for display: convert numbers/dates to words. * Only handles patterns that are likely to occur in Vietnamese assistant * responses. Runs ONLY on final transcripts, not partial deltas. */ export function normalizeVietnameseText(text) { if (!text || typeof text !== "string") return text; let result = text; // 1. Dates: dd/mm/yyyy result = result.replace( /\b(\d{1,2})\/(\d{1,2})\/(\d{4})\b/g, (_, d, m, y) => `ngày ${parseInt(d)} tháng ${parseInt(m)} năm ${numberToVietnamese(parseInt(y))}`, ); // 2. Percentages result = result.replace(/\b(\d+(?:[.,]\d+)?)%\b/g, (_, num) => { const val = parseFloat(num.replace(",", ".")); return Number.isInteger(val) ? `${numberToVietnamese(val)} phần trăm` : `${numberToVietnamese(Math.floor(val))} phẩy ${numberToVietnamese(Math.round((val % 1) * 100))} phần trăm`; }); // 3. Currency VND: 1.000.000₫ or 500k result = result.replace(/\b(\d{1,3}(?:\.\d{3})+)\s*(₫|đ|vnd)\b/gi, (_, num) => `${numberToVietnamese(parseInt(num.replace(/\./g, "")))} đồng`); result = result.replace(/\b(\d+)k\b/gi, (_, num) => `${numberToVietnamese(parseInt(num) * 1000)} đồng`); // 4. Decimals (not years — 1900-2100 are already handled by the model) result = result.replace(/\b(\d+)\.(\d{1,3})\b/g, (_, intPart, decPart) => { if (intPart.length > 4) return `${intPart}.${decPart}`; // not a decimal const decVal = parseInt(decPart); return `${numberToVietnamese(parseInt(intPart))} phẩy ${numberToVietnamese(decVal)}`; }); // 5. Large standalone numbers (3+ digits = years, prices, quantities) // Only replace if surrounded by Vietnamese text context result = result.replace(/\b(\d{3,})\b/g, (match) => { const n = parseInt(match); if (n >= 1900 && n <= 2100) return `năm ${numberToVietnamese(n)}`; return numberToVietnamese(n); }); // Clean spaces result = result.replace(/\s+/g, " ").trim(); return result; } /** * Check if text contains Vietnamese characters. */ export function containsVietnamese(text) { return /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(text); } /** * Smart normalizer: only run on Vietnamese text, only on final transcripts. * Pass `partial=false` from the transcript event to skip partial deltas. */ export function smartNormalize(text, partial = false) { if (!text || partial) return text; if (containsVietnamese(text)) { return normalizeVietnameseText(text); } return text; }