<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bùi Trọng Khánh Duy</title>
    <description>The latest articles on DEV Community by Bùi Trọng Khánh Duy (@khanhduyvt0101).</description>
    <link>https://dev.to/khanhduyvt0101</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1508828%2F33f92d87-a861-4c94-a8ac-adfdbb2fc7c3.png</url>
      <title>DEV Community: Bùi Trọng Khánh Duy</title>
      <link>https://dev.to/khanhduyvt0101</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/khanhduyvt0101"/>
    <language>en</language>
    <item>
      <title>I built a Discord bot that reads Vietnamese chat aloud. Here's what broke once real servers used it</title>
      <dc:creator>Bùi Trọng Khánh Duy</dc:creator>
      <pubDate>Sun, 01 Nov 2026 11:59:05 +0000</pubDate>
      <link>https://dev.to/khanhduyvt0101/i-built-a-discord-bot-that-reads-vietnamese-chat-aloud-heres-what-broke-once-real-servers-used-it-4207</link>
      <guid>https://dev.to/khanhduyvt0101/i-built-a-discord-bot-that-reads-vietnamese-chat-aloud-heres-what-broke-once-real-servers-used-it-4207</guid>
      <description>&lt;p&gt;Every Discord gaming group has one: the friend who can't talk. It's 1 a.m. and their family is asleep, or they're in a dorm, or the mic is just broken. So they type. And nobody in the middle of a match reads the chat.&lt;/p&gt;

&lt;p&gt;Discord has a built-in &lt;code&gt;/tts&lt;/code&gt;, but only people who have that text channel open hear it, and the Vietnamese voice reads chat the way it's written. Type "ko" (short for "không", "no") and you hear the letters K and O.&lt;/p&gt;

&lt;p&gt;I'm Khanh Duy, an indie developer in Ho Chi Minh City, and I built &lt;a href="https://voxybot.app" rel="noopener noreferrer"&gt;Voxy&lt;/a&gt; to fix that for Vietnamese servers: a bot that sits in a voice channel and reads that channel's text chat out loud. You type &lt;code&gt;/join&lt;/code&gt; once, and from then on whatever anyone types in the voice channel's chat is spoken to the room. Disclosure up front: it's my product, it's freemium, and this post is about the parts that turned out harder than I expected.&lt;/p&gt;

&lt;p&gt;The naive version is three lines: get message, call a text-to-speech API, play the audio into the voice connection. It works fine with two friends. Then real servers showed up.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_monCQCRBTU" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Nobody chats in textbook Vietnamese
&lt;/h2&gt;

&lt;p&gt;A normal message in a gaming server looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ae ơi tối nay 8h30 vô ko, ai thiếu 100k thì ib t nha kkkkk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly: "guys, you in tonight at 8:30? whoever's short 100k, DM me lol". Hand that straight to a TTS engine and you get letter soup: "a e", "tám hát ba mươi", "ka ô", "một trăm ka", and then five K's in a row.&lt;/p&gt;

&lt;p&gt;So every message goes through a "chat to speech" pass before synthesis:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shorthand&lt;/strong&gt;: &lt;code&gt;ko&lt;/code&gt;, &lt;code&gt;k&lt;/code&gt;, &lt;code&gt;hok&lt;/code&gt;, &lt;code&gt;khum&lt;/code&gt; all become "không"; &lt;code&gt;ae&lt;/code&gt; becomes "anh em"; &lt;code&gt;ib&lt;/code&gt; becomes "nhắn tin".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbers with units&lt;/strong&gt;: &lt;code&gt;100k&lt;/code&gt; is "100 nghìn", &lt;code&gt;8h30&lt;/code&gt; is a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laughter&lt;/strong&gt;: &lt;code&gt;kkkkk&lt;/code&gt; is a laugh, not five letters. A built-in &lt;code&gt;k&lt;/code&gt; never matches inside &lt;code&gt;100k&lt;/code&gt; or &lt;code&gt;kkkk&lt;/code&gt;, because a dictionary term needs word edges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Links and code blocks&lt;/strong&gt; are announced, not read character by character.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discord timestamps&lt;/strong&gt; like &lt;code&gt;&amp;lt;t:1792774733:R&amp;gt;&lt;/code&gt; are read as a real date and time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Word counting uses &lt;code&gt;Intl.Segmenter&lt;/code&gt; with the &lt;code&gt;vi&lt;/code&gt; locale, so I didn't have to write a Vietnamese word splitter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WORD_SEGMENTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;word&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;limitWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxWords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;wordCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;WORD_SEGMENTER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isWordLike&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;wordCount&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wordCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxWords&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;truncated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;truncated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each message is capped at 40 words. Longer ones are cut and end with ", còn nữa" ("there's more") so the room knows it was trimmed. Repeated characters are capped with a grapheme segmenter too, otherwise "hahahahaaaaaaa" becomes a very long second of everyone's evening.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shorthand list can't be global
&lt;/h3&gt;

&lt;p&gt;My first version had a hard-coded shorthand list. After it had run in real servers for a while, I had to pull an entry out because it meant different things in different servers. There is no dictionary that is right for everyone.&lt;/p&gt;

&lt;p&gt;Now each server has its own pronunciation dictionary (up to 300 words). The built-in shorthand is copied into it on first use, admins can edit or delete anything, and a deleted word never comes back on its own. One rule I'm glad I added early: built-in shorthand is never applied to display names. Otherwise a friend whose nickname happens to be a shorthand gets renamed every time they speak.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Busy servers made the bot slow, and TTS wasn't the culprit
&lt;/h2&gt;

&lt;p&gt;In small rooms everything felt instant. In a busy server, messages started arriving seconds late, sometimes after the conversation had moved on.&lt;/p&gt;

&lt;p&gt;I assumed the TTS API. It was actually a REST call I made for every message to fetch the member and check they were still in the room and allowed to use the bot. In large servers that call took anywhere from a quarter of a second to several seconds.&lt;/p&gt;

&lt;p&gt;What fixed it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No per-message member fetch.&lt;/strong&gt; Use the cached roles from when the message was sent, and re-check the room and permissions once, right before playback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthesize the next message while the current one plays.&lt;/strong&gt; There are 8 synthesis slots shared across rooms, and work-ahead only runs while 2 of them stay free, so one loud server can't starve a message that needs to play now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop stale messages.&lt;/strong&gt; Anything older than 30 seconds isn't read. By then it's no longer part of the conversation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point 2 has a cost: if a message is synthesized ahead and then deleted, I've still paid for that synthesis. Speed was worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. One bot can only be in one voice channel per server
&lt;/h2&gt;

&lt;p&gt;Discord lets a bot be connected to one voice channel per server at a time. Big servers often have several rooms going at once.&lt;/p&gt;

&lt;p&gt;So Voxy has three helper bots, Voxy 2, 3 and 4. If Voxy is busy in another room, &lt;code&gt;/join&lt;/code&gt; offers to add a helper, which gives up to 4 rooms read in parallel in one server. Settings and the server's credit balance are shared between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Releases used to kick everyone out of the room
&lt;/h2&gt;

&lt;p&gt;This is the one that cost me the most sleep. Early on, every deploy or host restart disconnected every room, and people had to &lt;code&gt;/join&lt;/code&gt; again. As a solo developer I ship several times a day, so this happened a lot.&lt;/p&gt;

&lt;p&gt;Two facts shaped the fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discord keeps an orphaned bot in its voice channel for about &lt;strong&gt;118 seconds&lt;/strong&gt; after the process disappears.&lt;/li&gt;
&lt;li&gt;The bot's own voice state, as Discord reports it on a fresh READY, is the source of truth. Not my database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Short restarts: hand off instead of leaving.&lt;/strong&gt; On a release (and on a crash, which takes the same path) the bot stops speech, settles billing, closes its stores and exits &lt;em&gt;without&lt;/em&gt; sending a voice leave or closing the gateway politely. The supervisor starts the new process within seconds, and on READY it re-adopts every room that Discord still lists for it, well inside the 118-second window. To the people in the room, the bot never left.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Longer outages: rejoin, carefully.&lt;/strong&gt; Each live session is stored as one row (voice channel, chat channel being read, &lt;code&gt;last_seen_at&lt;/code&gt; updated every 60 seconds). On startup, after re-adopting what Discord still lists, the bot rejoins a stored session only if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it's at most 30 minutes old,&lt;/li&gt;
&lt;li&gt;the room still has a real (non-bot) member,&lt;/li&gt;
&lt;li&gt;the bot can still connect and speak there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rejoins are spaced 1.5 seconds apart. The row is deleted the moment a session ends on purpose: &lt;code&gt;/leave&lt;/code&gt;, someone disconnecting the bot, the channel being deleted, the bot being removed from the server. That last part matters most. A bot that "helpfully" walks back into a room it was just kicked out of is worse than one that never reconnects.&lt;/p&gt;

&lt;p&gt;Messages typed while the bot is reconnecting are held and read in order once audio is back, as long as they're still under 30 seconds old.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you build a voice bot: trust the voice state Discord sends you about your own bot. Saved state is a hint, never an instruction to join.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Money, without charging anyone wrongly
&lt;/h2&gt;

&lt;p&gt;TTS is billed per character, so it can't be free forever. Right now the standard voice is free for 20,000 characters per server per day, and HD voices use credits from one-time packs. No subscription.&lt;/p&gt;

&lt;p&gt;The part I spent the most care on is the ledger. Credits are reserved when synthesis starts and only charged when it finishes. The easy-to-miss case is a message cancelled mid-synthesis while the provider request is still in flight. The reservation isn't released until that request actually settles, so I never refund credits for audio the provider still bills me for. And the browser never adds credits by itself. Only the backend, after the payment is confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bot&lt;/strong&gt;: TypeScript, discord.js 14, @discordjs/voice, on Node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo&lt;/strong&gt;: Bun workspaces. A shared &lt;code&gt;core&lt;/code&gt; package holds product facts, plus contract, db (Drizzle + SQLite), bot, server and client packages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Website&lt;/strong&gt;: React, static build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt;: Docker on a single VPS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule I'd recommend to anyone shipping alone: &lt;strong&gt;every public number lives in one file.&lt;/strong&gt; Free characters per day, number of voices, bots per server, the word limit. A test fails if any sentence on the website, the help pages or the changelog disagrees with that file. So the website can't quietly drift away from what the bot actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;Voxy has 10 Vietnamese voices (each member can pick their own with &lt;code&gt;/myvoice&lt;/code&gt;), speed from 0.5x to 2x, and as of today it's in about 24 servers. Small, and I'm fine with that. It's built for one language and one kind of room.&lt;/p&gt;

&lt;p&gt;If you run a Vietnamese Discord server, the full command list is at &lt;a href="https://voxybot.app/commands/" rel="noopener noreferrer"&gt;voxybot.app/commands&lt;/a&gt;. And if you build voice bots in other languages, I'd like to hear how you handle shorthand and slang, or what broke for you once real users arrived. Comments are open.&lt;/p&gt;

</description>
      <category>discord</category>
      <category>typescript</category>
      <category>node</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
