<?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: Divyajeet01</title>
    <description>The latest articles on DEV Community by Divyajeet01 (@divyajeet01).</description>
    <link>https://dev.to/divyajeet01</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%2F4240417%2Fd81bc066-fd4d-447b-9030-a9cdb29f9a89.jpg</url>
      <title>DEV Community: Divyajeet01</title>
      <link>https://dev.to/divyajeet01</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/divyajeet01"/>
    <language>en</language>
    <item>
      <title>Why your voice agent says "asterisk asterisk"</title>
      <dc:creator>Divyajeet01</dc:creator>
      <pubDate>Thu, 12 Nov 2026 07:04:50 +0000</pubDate>
      <link>https://dev.to/divyajeet01/why-your-voice-agent-says-asterisk-asterisk-58ci</link>
      <guid>https://dev.to/divyajeet01/why-your-voice-agent-says-asterisk-asterisk-58ci</guid>
      <description>&lt;p&gt;You build a voice agent. The LLM is sharp, the TTS voice is warm, the latency is&lt;br&gt;
good. You put it on a real phone number and call it.&lt;/p&gt;

&lt;p&gt;And it says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"asterisk asterisk invoice hash forty two asterisk asterisk colon dollar one&lt;br&gt;
comma two three four point five six due two zero two six dash one one dash&lt;br&gt;
one one"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What the LLM actually returned was perfectly reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;**Invoice #42**: $1,234.56 due 2026-11-11.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM did nothing wrong. The TTS engine did nothing wrong. The bug is in the&lt;br&gt;
gap between them, and almost nobody budgets for it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;LLMs are trained overwhelmingly on written English — documentation, Markdown,&lt;br&gt;
code, articles. So they emit written English: &lt;code&gt;**bold**&lt;/code&gt;, &lt;code&gt;$1,234.56&lt;/code&gt;, &lt;code&gt;Dr.&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;2026-11-11&lt;/code&gt;, bullet lists, emoji.&lt;/p&gt;

&lt;p&gt;TTS engines expect prose. Good ones handle a few of these. None handle all of&lt;br&gt;
them, and which ones they handle is undocumented and changes between providers&lt;br&gt;
and between versions of the same provider. Switch your TTS vendor and a&lt;br&gt;
different subset of your text starts being mispronounced.&lt;/p&gt;

&lt;p&gt;So the normalization has to happen in your code, before synthesis.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix everyone writes first
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"#"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This gets you through the demo. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$1,234.56&lt;/code&gt; is still read as digits&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(555) 123-4567&lt;/code&gt; is read as "five hundred fifty-five, one hundred twenty-three..."
instead of digit-by-digit, which is how humans read phone numbers aloud&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Dr. Rao&lt;/code&gt; becomes "dee are rao"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2026-11-11&lt;/code&gt; becomes "two zero two six dash one one dash one one"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;3:30 PM&lt;/code&gt; is fine, but &lt;code&gt;14:00&lt;/code&gt; becomes "fourteen o'clock"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So you add regexes. One per symptom, in the order you discovered them. And that&lt;br&gt;
is where it gets genuinely tricky.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that's actually hard: ordering
&lt;/h2&gt;

&lt;p&gt;These rules are not independent. They fight each other, and the order you apply&lt;br&gt;
them in determines whether the output is right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dates must run before phone numbers.&lt;/strong&gt; A phone-number matcher looks for runs&lt;br&gt;
of digits, hyphens and spaces. So does &lt;code&gt;2026-11-11&lt;/code&gt;. If phones run first, your&lt;br&gt;
date is read out as a phone number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emails and URLs must run before anything that touches a dot.&lt;/strong&gt; Once an&lt;br&gt;
abbreviation rule has rewritten the &lt;code&gt;.&lt;/code&gt; in &lt;code&gt;john.doe@example.com&lt;/code&gt;, you can't&lt;br&gt;
recover the address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Currency must run before plain integers.&lt;/strong&gt; If a generic number rule reaches&lt;br&gt;
&lt;code&gt;$1,234.56&lt;/code&gt; first, you get "one thousand two hundred thirty-four point five six"&lt;br&gt;
with the dollars silently dropped — the worst kind of bug, because it sounds&lt;br&gt;
fluent and is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Markdown stripping must run first of all&lt;/strong&gt;, or &lt;code&gt;**$5**&lt;/code&gt; never matches your&lt;br&gt;
currency pattern because of the asterisks glued to it.&lt;/p&gt;

&lt;p&gt;Here's a concrete one that cost me an afternoon. This looks fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"\b(\d{1,2}):(\d{2})\s*([ap]\.?m\.?)?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;say_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Given &lt;code&gt;"Call at 3:30 PM."&lt;/code&gt; it produces &lt;code&gt;"Call at three thirty PM"&lt;/code&gt; — note the&lt;br&gt;
missing full stop. The optional meridiem group happily absorbed the&lt;br&gt;
sentence-ending period as part of &lt;code&gt;PM.&lt;/code&gt;. The fix is to not let it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"\b(\d{1,2}):(\d{2})(?:\s*([ap]\.?m))?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;say_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the same pattern has a second bug: with &lt;code&gt;\s*&lt;/code&gt; outside the optional group,&lt;br&gt;
&lt;code&gt;"Departs at 14:00 sharp"&lt;/code&gt; becomes &lt;code&gt;"...two o'clock PMsharp"&lt;/code&gt;, because the&lt;br&gt;
whitespace was consumed even when no meridiem was found. Both of these produce&lt;br&gt;
output that &lt;em&gt;looks&lt;/em&gt; right in a unit test you wrote carelessly, and sounds&lt;br&gt;
obviously broken on a phone call.&lt;/p&gt;

&lt;p&gt;Phone numbers have a similar trap. This pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"(?&amp;lt;![\w$.])\+?\d[\d\-.\s()]{6,18}\d(?![\w])"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;starts matching at the first &lt;em&gt;digit&lt;/em&gt;, so &lt;code&gt;(555) 123-4567&lt;/code&gt; leaves a dangling &lt;code&gt;(&lt;/code&gt;&lt;br&gt;
in your output. The opening paren has to be inside the match.&lt;/p&gt;

&lt;p&gt;And one more that only shows up in production. If you stream tokens, or the&lt;br&gt;
model hits its output limit mid-sentence, you get &lt;em&gt;unpaired&lt;/em&gt; markers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;**Your total is
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every emphasis regex is written to match pairs, so an unclosed &lt;code&gt;**&lt;/code&gt; matches&lt;br&gt;
nothing and sails straight through to the synthesizer. Your carefully tested&lt;br&gt;
markdown stripper produces "asterisk asterisk your total is" on exactly the&lt;br&gt;
responses that were already degraded. Strip any marker that survives the&lt;br&gt;
pairing rules.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading numbers the way people do
&lt;/h2&gt;

&lt;p&gt;Not every number is read the same way, and the difference is not cosmetic:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;4567&lt;/code&gt; is read as&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quantity&lt;/td&gt;
&lt;td&gt;"four thousand five hundred sixty-seven"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone number&lt;/td&gt;
&lt;td&gt;"four five six seven"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Year&lt;/td&gt;
&lt;td&gt;"forty-five sixty-seven"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PIN or code&lt;/td&gt;
&lt;td&gt;"four five six seven"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Years have their own rules that no general number function gets right: &lt;code&gt;2026&lt;/code&gt; is&lt;br&gt;
"twenty twenty-six", &lt;code&gt;2005&lt;/code&gt; is "two thousand five", and &lt;code&gt;1905&lt;/code&gt; is "nineteen oh&lt;br&gt;
five". Three different forms, no clean rule — you special-case them or you sound&lt;br&gt;
like a robot.&lt;/p&gt;
&lt;h2&gt;
  
  
  So I packaged it
&lt;/h2&gt;

&lt;p&gt;I kept rewriting this layer, so I wrote it once properly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip install speakable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;speakable&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;

&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"**Invoice #42**: $1,234.56 due 2026-11-11."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# 'Invoice number forty-two: one thousand two hundred thirty-four dollars&lt;/span&gt;
&lt;span class="c1"&gt;#  and fifty-six cents due November eleventh, twenty twenty-six.'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a LiveKit agent it's one line, right before synthesis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;say&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm_response&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;It handles markdown and emoji stripping, currency in five denominations, dates&lt;br&gt;
in ISO/US/written forms, 12- and 24-hour times, phone numbers, percentages,&lt;br&gt;
ordinals, units, abbreviations, emails and URLs, and optional acronym spelling.&lt;br&gt;
Zero dependencies in the core, around 1.5 ms per utterance, and idempotent —&lt;br&gt;
running it twice changes nothing.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; yet handle non-English locales, Indian lakh/crore grouping,&lt;br&gt;
roman numerals, or fractions. If you need those, the issues are open.&lt;/p&gt;

&lt;p&gt;MIT licensed. Source: &lt;code&gt;github.com/Divyajeet01/speakable&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway, even if you don't use it
&lt;/h2&gt;

&lt;p&gt;Treat text normalization as a real stage in your pipeline, not a &lt;code&gt;.replace()&lt;/code&gt;&lt;br&gt;
you bolt on the day before launch. Give it its own module, its own tests, and —&lt;br&gt;
most importantly — a deliberate, documented order. The ordering is the design.&lt;/p&gt;

&lt;p&gt;And test it by &lt;em&gt;listening&lt;/em&gt;, not by reading. Every bug above passes a code review&lt;br&gt;
and fails a phone call.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
