0

Mình làm bot Discord đọc tin nhắn tiếng Việt: 5 bài toán không ngờ tới khi có người dùng thật

Hello anh em 👋

Chắc ai chơi game với hội bạn trên Discord cũng từng gặp cảnh này: cả phòng đang nói chuyện rôm rả, còn một đứa thì không bật mic được. Đứa thì đang ở ký túc xá, đứa thì 1 giờ sáng sợ cả nhà thức, đứa thì mic hỏng. Thế là nó gõ chat, mà giữa trận thì chẳng ai rảnh liếc sang khung chat cả 😅

Discord có sẵn lệnh /tts, nhưng chỉ người đang mở kênh chat mới nghe, giọng đọc tiếng Việt không tự nhiên, mà gõ "ko", "mn ơi", "8h30 nhé" vào là nó đọc y nguyên từng chữ cái.

Nên mình làm Voxy: một bot Discord đọc tin nhắn tiếng Việt ngay trong kênh thoại. Gõ /join một lần, từ đó ai gõ gì trong chat của kênh thoại, cả phòng đều nghe thấy.

Voxy - Bot Discord đọc tin nhắn tiếng Việt

Nghe thì đơn giản: nhận tin nhắn → gọi API text-to-speech → phát vào voice. Nhưng khi có người dùng thật thì mới lòi ra cả đống bài toán mình không hề nghĩ tới. Bài này mình kể lại 5 bài toán đó và cách mình xử lý, hy vọng giúp được anh em nào cũng đang làm bot hay app có giọng nói.

Demo 1 phút cho anh em dễ hình dung (nhớ bật loa nhé):


1. Chat tiếng Việt không phải tiếng Việt chuẩn

Bài học đầu tiên: không ai chat như sách giáo khoa cả.

Một tin nhắn bình thường trong phòng game trông thế này:

ae ơi tối nay 8h30 vô ko, ai thiếu 100k thì ib t nha kkkkk

Đưa thẳng cho engine TTS thì nó đọc ra "a e ơi... tám hát ba mươi... ka ô... một trăm ka...". Nghe xong cả phòng cười chứ chẳng ai hiểu gì.

Nên trước khi đọc, mỗi tin nhắn đi qua một chuỗi bước "dịch chat sang lời nói":

  • Teencode và viết tắt: ko/k/hok/khum → "không", mn/mng → "mọi người", ae → "anh em", dc/đc → "được", ib → "nhắn tin"... Bộ có sẵn 82 từ.
  • Số kèm đơn vị: 100k → "100 nghìn", 2tr → "2 triệu", 15p → "15 phút".
  • Tiếng cười: kkkkk là cười, không phải đọc "ka ka ka ka ka".
  • Link và code: không ai muốn nghe đọc nguyên một đường URL, nên chỉ đọc là "đường dẫn" hoặc "đoạn code".
  • Timestamp của Discord như <t:1792774733:R> được đọc thành giờ và ngày.

Gõ chat bình thường, Voxy đọc cho cả phòng

Đoạn tách từ dùng luôn Intl.Segmenter có sẵn trong JavaScript với locale vi, đỡ phải tự viết bộ tách từ tiếng Việt:

const WORD_SEGMENTER = new Intl.Segmenter("vi", { granularity: "word" });

function limitWords(input: string, maxWords: number) {
  let wordCount = 0;
  for (const segment of WORD_SEGMENTER.segment(input)) {
    if (!segment.isWordLike) continue;
    wordCount += 1;
    if (wordCount > maxWords) {
      return { text: input.slice(0, segment.index).trim(), truncated: true };
    }
  }
  return { text: input, truncated: false };
}

Mỗi tin chỉ đọc tối đa 40 từ. Tin dài hơn sẽ bị cắt, kèm câu ", còn nữa" để cả phòng biết là còn.

Cái khó: teencode mỗi server một kiểu

Ban đầu mình hard-code danh sách teencode. Chạy thật một thời gian thì thấy có từ đọc ra sai nghĩa tuỳ ngữ cảnh, ví dụ "nt", và mình phải gỡ hẳn ra khỏi danh sách 😂

Bài học là không có bộ từ điển nào đúng cho mọi người. Nên giờ mỗi server có từ điển phát âm riêng (/dictionary, tối đa 300 từ):

  • Lần đầu dùng, 82 từ teencode có sẵn được chép vào từ điển của server.
  • Server sửa hay xoá từ nào cũng được, và từ đã xoá thì không bao giờ tự quay lại.
  • Teencode có sẵn không áp vào tên người. Không thì bạn tên "Ny" sẽ bị gọi là "người yêu" 🙃

Cái khó tiếp: spam

Phòng game thì chắc chắn có người gõ "hahahahahahahahaha" hoặc dán 10 cái emoji liên tiếp. Đọc hết thì tra tấn cả phòng, nên có vài luật chặn:

  • Một ký tự lặp quá 3 lần thì cắt: "hahahahaaaaaa" → "hahahahaaa".
  • Một cụm từ lặp quá 2 lần thì gộp: "gg gg gg gg gg" → "gg gg".
  • Tin có từ 4 emoji trở lên được coi là spam emoji: bỏ emoji, chỉ đọc chữ.

2. Phòng càng đông, bot càng chậm

Ở phòng ít người thì mọi thứ chạy ngon. Nhưng lên một server game đông người thì tin nhắn bắt đầu bị đọc trễ vài giây, có lúc trễ tới mức người ta đã nói sang chuyện khác.

Đo thử thì thủ phạm không nằm ở TTS mà ở một lệnh gọi REST lấy thông tin thành viên cho mỗi tin nhắn, để kiểm tra người gửi có đang ở trong phòng và có quyền dùng bot không. Ở server đông, lệnh này mất từ 0,25 đến 6 giây.

Cách sửa:

  1. Bỏ lệnh fetch cho từng tin. Dùng role đã có trong cache lúc tin được gửi, rồi kiểm tra lại phòng và quyền ở bước cuối, ngay trước khi phát.
  2. Tổng hợp trước tin tiếp theo. Trong lúc tin hiện tại đang phát thì tin kế tiếp trong hàng đợi đã được gửi đi tổng hợp giọng. Có 8 "slot" tổng hợp chạy song song, luôn chừa 2 slot cho tin mới để một phòng đông không chiếm hết.
  3. Bỏ qua tin quá cũ. Tin đã gửi hơn 30 giây thì không đọc nữa, vì lúc đó câu chuyện đã trôi qua rồi.

Mục 2 có một cái giá: nếu tin đã tổng hợp trước mà bị huỷ (ví dụ người gửi xoá tin) thì phần tổng hợp đó vẫn tốn tiền API. Mình chấp nhận đánh đổi này, vì đọc nhanh quan trọng hơn hẳn.


3. Một bot không đủ cho một server

Server đông thường có nhiều phòng chơi cùng lúc. Vấn đề là Discord chỉ cho một bot kết nối một kênh thoại trong mỗi server tại một thời điểm. Muốn đọc nhiều phòng thì phải có nhiều bot.

Nên Voxy có thêm 3 bot phụ là Voxy 2, 3 và 4. Nếu Voxy đang bận ở phòng khác thì /join sẽ gợi ý thêm Voxy 2, 3 hoặc 4 vào server để đọc song song, tối đa 4 phòng cùng lúc trong một server. Credit thì vẫn dùng chung một ví của server.


4. Bot rớt khỏi phòng khi server khởi động lại

Đây là bài toán làm mình mất ngủ nhất 😩

Lúc đầu, mỗi lần mình deploy bản mới hay VPS khởi động lại, tất cả các phòng đang dùng bot đều bị đá văng ra, và mọi người phải /join lại. Mà làm một mình thì một ngày deploy vài lần là chuyện thường.

Có vài thứ phải xử lý:

a. Tự kết nối lại sau khi gián đoạn. Discord giữ một bot "mồ côi" trong phòng khoảng 118 giây. Gián đoạn lâu hơn thì bot coi như đã rời phòng. Nên mỗi phiên đọc được lưu lại một dòng (phòng thoại, kênh chat đang đọc, thời điểm còn sống, cập nhật mỗi 60 giây). Khi bot khởi động lại, phiên nào chưa quá 30 phút, phòng vẫn còn người thật và bot vẫn có quyền vào và nói thì bot tự vào lại, mỗi phòng cách nhau 1,5 giây cho khỏi dồn dập. Dòng này bị xoá ngay khi người dùng chủ động /leave hoặc kick bot ra, nên bot không bao giờ tự mò vào phòng mà người ta đã đuổi nó đi.

b. Không làm rơi tin nhắn lúc đang kết nối lại. Tin gửi trong lúc bot đang vào lại phòng sẽ được giữ trong bộ đệm (tối đa 50 tin mỗi server, mỗi tin giữ tối đa 30 giây), rồi đọc lần lượt đúng thứ tự khi có tiếng trở lại. Tin bị xoá trong lúc chờ thì bỏ qua.

c. Deploy mà không đá ai ra. Phần này mình làm khá kỹ: bản mới được dựng sẵn bên cạnh bản cũ, rồi các phòng đang đọc được bàn giao sang. Lần deploy gần nhất, 5 phòng đang đọc được bàn giao và kết nối lại trong khoảng 40 giây, không ai phải gõ lại lệnh nào.

Tip cho anh em làm bot voice: voice state mà Discord gửi về cho chính bot của mình mới là nguồn sự thật. Đừng tin dữ liệu đã lưu trong DB để quyết định bot đang ở phòng nào.


5. Tiền: làm sao để không lỗ và không thu sai của ai

Gọi API giọng đọc thì tốn tiền theo từng ký tự, nên không thể miễn phí mãi. Mình chọn mô hình credit dùng chung cho cả server:

  • Mỗi server được tặng 5.000 credit dùng thử một lần.
  • Giọng mặc định tốn 1 credit/ký tự, các giọng HD tốn 8 credit/ký tự.
  • Hết thì ai trong server cũng có thể nạp gói (từ 29.000đ cho 50.000 credit), trả một lần bằng mã QR chuyển khoản, không gia hạn tự động.

Phần mình để tâm nhất là sổ cái. Khi một tin bắt đầu được tổng hợp thì credit được giữ chỗ trước, đọc xong mới trừ thật. Có một trường hợp hay bị quên: tin bị huỷ giữa chừng nhưng request tổng hợp vẫn đang chạy. Lúc đó phần credit giữ chỗ chưa được trả lại cho tới khi request thật sự kết thúc, để không bao giờ có cảnh vừa trả credit vừa bị nhà cung cấp tính tiền.

Trình duyệt cũng không bao giờ tự cộng credit. Chỉ khi backend xác nhận đã nhận tiền thì credit mới vào tài khoản server.


Stack cho anh em tò mò

  • Bot: TypeScript, discord.js 14, @discordjs/voice, chạy bằng Node.
  • Monorepo Bun workspaces, 6 package: core (dùng được cả trên trình duyệt), contract (API contract-first với oRPC và zod), db (Drizzle + SQLite), bot, server, client.
  • Website: React, build tĩnh, chạy trên Cloudflare Pages.
  • Backend: Docker trên một VPS.
  • Test: hơn 1.100 test với coverage 100% theo từng file. Mình làm một mình nên test là cái phao cứu sinh mỗi lần deploy 😅

Một quy tắc mình thấy rất đáng: mỗi con số chỉ định nghĩa ở một chỗ. Số credit dùng thử, số giọng, số bot... đều nằm trong một file, và có test kiểm tra mọi câu trên website, trang hướng dẫn và changelog đều khớp với file đó. Nhờ vậy website không bao giờ ghi sai số so với bot.


Kết

Voxy hiện có 10 giọng tiếng Việt (đổi bằng /voices, chỉnh tốc độ từ 0.5× đến 2× bằng /speed), mỗi người tự chọn giọng riêng với /myvoice, và có thể chọn ai được đọc với /set roles.

10 giọng tiếng Việt

Nếu anh em có server Discord và có người hay ngại bật mic thì thử nhé, có thể nghe thử các giọng ngay trên web mà không cần cài gì:

Nghe thử giọng trên website

👉 https://voxy.khanhduy.com

Mỗi server có 5.000 credit dùng thử, đủ để cả phòng chat một buổi.

Mình làm một mình nên chắc chắn còn nhiều chỗ chưa ổn. Anh em có góp ý gì về cách xử lý tiếng Việt, kiến trúc bot voice hay muốn mình viết sâu hơn phần nào (deploy không rớt phòng, sổ cái credit...) thì comment bên dưới nha. Cảm ơn anh em đã đọc tới đây 🙏


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí