Nối text-to-speech vào workflow: phần khó không nằm ở API

hecigo7 min read
Text-to-SpeechTích hợp hệ thốngn8nTiếng ViệtTự động hóa

Google phát hành model text-to-speech mới trong dòng Gemini Flash, đọc được tiếng Việt và điều khiển được ngữ điệu bằng chỉ dẫn văn bản (thông báo của Google, tài liệu API).

Gọi được API đó mất khoảng mười phút. Bài này nói về phần còn lại: những gì phải làm trước và sau lời gọi đó để nó chạy được trong một quy trình thật.

Chuẩn hóa tiếng Việt trước khi đọc

Đây là phần chiếm nhiều thời gian nhất và cũng là phần hay bị bỏ qua nhất.

Văn bản trong hệ thống doanh nghiệp không phải văn xuôi. Nó đầy số tiền, ngày tháng, mã đơn, viết tắt, và tên riêng. Model TTS đọc chúng theo cách nó đoán, và đoán sai thì người nghe nhận ra ngay.

Vài trường hợp gặp trong hầu hết dự án:

Trong dữ liệuĐọc sai thànhPhải chuẩn hóa thành
1.500.000đ"một chấm năm trăm nghìn""một triệu năm trăm nghìn đồng"
TP.HCM"tê pê chấm hát xê mờ""Thành phố Hồ Chí Minh"
30/4"ba mươi trên bốn""ngày ba mươi tháng tư"
0912345678đọc liền như một số"không chín một hai, ba bốn năm, sáu bảy tám"
SP-A12"ét pê a mười hai""sản phẩm A mười hai"
12,5%"mười hai phẩy năm phần trăm" (đúng)giữ nguyên

Hai điều đáng lưu ý về bảng này. Thứ nhất, nó phụ thuộc miền dữ liệu: bảng cho hệ thống bán lẻ khác bảng cho hệ thống y tế. Thứ hai, nó phải sống ngoài mã nguồn, vì người sửa nó thường là người vận hành chứ không phải lập trình viên, và mỗi lần sửa mà phải deploy là một lần không ai sửa.

Cách làm dùng được là một bảng quy tắc có thứ tự, áp dụng từ cụ thể tới tổng quát:

// Quy tắc chuẩn hóa chạy TRƯỚC khi gửi cho TTS.
// Thứ tự quan trọng: khớp cụ thể trước, tổng quát sau. Đảo lại thì quy tắc
// tiền tệ sẽ nuốt mất số điện thoại, vì cả hai đều là chuỗi chữ số dài.
const RULES = [
  { name: 'tien-te',    re: /(\d[\d.]*)\s*|VND|vnđ)\b/gi, fn: (_, n) => `${docSoTien(n)} đồng` },
  { name: 'sdt',        re: /\b(0\d{9,10})\b/g,             fn: (_, n) => docTungCum(n) },
  { name: 'ngay-thang', re: /\b(\d{1,2})\/(\d{1,2})\b/g,    fn: (_, d, m) => `ngày ${d} tháng ${m}` },
  { name: 'viet-tat',   re: /\bTP\.?\s?HCM\b/gi,            fn: () => 'Thành phố Hồ Chí Minh' },
];
 
function chuanHoa(text) {
  const applied = [];
  let out = text;
  for (const r of RULES) {
    const before = out;
    out = out.replace(r.re, r.fn);
    if (out !== before) applied.push(r.name);
  }
  // Ghi lại quy tắc nào đã chạy: khi người nghe báo "đọc sai chỗ này",
  // đây là thứ duy nhất cho biết nên sửa quy tắc nào.
  return { text: out, applied };
}

Trả về cả applied chứ không chỉ chuỗi kết quả. Khi có người báo một câu bị đọc sai, thứ bạn cần là biết quy tắc nào đã can thiệp vào câu đó, chứ không phải đọc lại toàn bộ danh sách quy tắc để đoán.

Audio đi đâu sau khi sinh ra

API trả về dữ liệu âm thanh dạng nhị phân. Ba câu hỏi phải trả lời trước khi viết dòng code đầu tiên:

Lưu ở đâu. Nhét base64 vào một trường trong cơ sở dữ liệu là cách nhanh nhất và cũng là cách hối hận nhanh nhất: một đoạn audio ba mươi giây đã vài trăm KB, và mọi truy vấn chạm vào bảng đó đều chậm đi. Lưu ra object storage, giữ lại đường dẫn.

Sinh lại hay dùng lại. Cùng một câu, cùng một giọng, cùng một tham số thì kết quả không đổi. Băm chuỗi sau khi chuẩn hóa cộng với id giọng đọc làm khóa cache. Với thông báo theo mẫu, tỉ lệ trùng rất cao và mỗi lần trùng là một lần không phải trả tiền.

Bao giờ xóa. Audio thông báo đơn hàng không cần sống mãi. Không đặt hạn xóa từ đầu thì sáu tháng sau sẽ có người phải đi dọn thủ công.

Thất bại giữa chừng và chi phí

TTS tính tiền theo lượng đầu vào, nên mỗi lần gọi lại là một lần trả tiền lần nữa (bảng giá Gemini API). Điều đó đổi cách thiết kế phần thử lại.

Với một lời gọi API thông thường, thử lại là chuyện rẻ. Với TTS, một vòng lặp retry chạy sai có thể sinh hóa đơn thật. Ba việc tối thiểu:

  1. Ghi nhận trước khi gọi. Cùng cơ chế sổ chống trùng như mọi tích hợp khác: khóa là băm của văn bản đã chuẩn hóa cộng id giọng. Nếu khóa đã tồn tại và có đường dẫn audio, dừng lại và trả về đường dẫn cũ.
  2. Giới hạn số lần thử. Ba lần với backoff tăng dần, rồi đẩy sang hàng đợi thất bại. Đừng thử vô hạn.
  3. Kiểm tra audio trước khi coi là thành công. Một phản hồi HTTP 200 kèm 40 byte không phải audio. Kiểm độ dài tối thiểu và header định dạng trước khi ghi vào sổ, nếu không thì lần sau cache sẽ trả về một file rỗng và không ai hiểu vì sao.

Khi nào thì đáng làm

Text-to-speech đáng làm khi người nhận không rảnh tay hoặc không rảnh mắt: tài xế nghe thông báo đơn mới, công nhân kho nghe lệnh lấy hàng, người khiếm thị dùng hệ thống nội bộ.

Nó không đáng làm khi chỉ để thay một dòng chữ mà người ta vẫn đang đọc bình thường. Chuyển thông báo Zalo thành audio không làm nó được đọc nhiều hơn, chỉ làm nó khó lướt qua hơn.

Về phần hecigo: chúng tôi chưa phát hành node TTS nào. Hai node công khai hiện có trên npm là n8n-nodes-zalo-platformn8n-nodes-firecrawl-v2. Phần trên là cách chúng tôi tiếp cận một tích hợp dạng này, không phải mô tả một sản phẩm đã có.

Nếu bạn đang nối một dịch vụ bên ngoài vào quy trình nội bộ và vướng đúng ở phần "sau khi API trả về thì làm gì", mô tả cho chúng tôi hai hệ thống đó và chỗ nào đang sai.

🚀

Thấy hữu ích? Theo dõi hecigo trên Zalo OA để nhận bài viết kỹ thuật mới sớm nhất - không spam, chỉ nội dung thực tế. Hoặc liên hệ trực tiếp nếu bạn cần hỗ trợ triển khai.

📖

Đọc tiếp: Middleware: phần việc n8n, OpenClaw và mọi nền tảng tự động hóa không làm hộ bạn

Nối được API là phần dễ. Phần khó lộ ra sau vài tuần chạy thật: sự kiện gửi lại hai lần, webhook rơi mất một giao dịch, hóa đơn bị hủy nhưng hệ...

Nguồn tham khảo

Related Articles