Spaces:
Running
Running
restore to c1013cc7ebfdc93cbb55645441528a84d871a052: 100% restore of target commit tree
9b44afa verified | /** | |
| * 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; | |
| } | |