<?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: Karthik R</title>
    <description>The latest articles on DEV Community by Karthik R (@karthik_r_rk).</description>
    <link>https://dev.to/karthik_r_rk</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%2F4046343%2F0147712a-dce1-4c91-9a16-e697545bd6b2.jpg</url>
      <title>DEV Community: Karthik R</title>
      <link>https://dev.to/karthik_r_rk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/karthik_r_rk"/>
    <language>en</language>
    <item>
      <title>I made my voice agent slower on purpose</title>
      <dc:creator>Karthik R</dc:creator>
      <pubDate>Sat, 25 Jul 2026 04:53:26 +0000</pubDate>
      <link>https://dev.to/karthik_r_rk/i-made-my-voice-agent-slower-on-purpose-ci</link>
      <guid>https://dev.to/karthik_r_rk/i-made-my-voice-agent-slower-on-purpose-ci</guid>
      <description>&lt;p&gt;&lt;em&gt;My pipeline was fast enough to interrupt people. Here is what I changed, what it cost, and the second bug that made calls go deaf without anyone noticing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run a voice AI sales training platform. Users practice cold calls against an AI buyer, in real time, over WebRTC. The stack is Pipecat for orchestration, LiveKit for transport, Deepgram Flux for STT, Cartesia Sonic for TTS, and a small fast model over OpenRouter for the conversation.&lt;/p&gt;

&lt;p&gt;In July I had a pipeline I was proud of: &lt;strong&gt;p95 of about 880 ms&lt;/strong&gt; from confirmed end of turn to the first audio byte coming back. Then I listened to real calls, and the AI buyer kept talking over people.&lt;/p&gt;

&lt;p&gt;Not at natural turn boundaries. Mid-sentence. A rep would say &lt;em&gt;"so what we do is..."&lt;/em&gt;, pause half a second to think, and the buyer would answer the unfinished thought.&lt;/p&gt;

&lt;p&gt;The pipeline was working exactly as designed. That was the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed made it worse, not better
&lt;/h2&gt;

&lt;p&gt;My endpointing was pinned at the aggressive end of the config range:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VOICE_EOT_THRESHOLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VOICE_EOT_TIMEOUT_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eager end-of-turn&lt;/td&gt;
&lt;td&gt;on, at 0.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn-commit buffer&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A one-second thinking pause was enough to commit the turn. Then an 880 ms pipeline delivered a full reply before the caller got going again.&lt;/p&gt;

&lt;p&gt;Here is the part I had not internalized. &lt;strong&gt;If my pipeline had been slower, the same premature endpointing would have been less noticeable.&lt;/strong&gt; The reply would have landed after the caller resumed, barge-in would have caught it, and the turn would have recovered. Being fast is what converted a detection error into an interruption.&lt;/p&gt;

&lt;p&gt;Latency and endpointing are not independent. Every millisecond you shave off the pipeline raises the cost of a premature end-of-turn decision. Speed is not free. It amplifies your endpointing errors.&lt;/p&gt;

&lt;p&gt;Almost every voice latency post I have read treats the pipeline as the thing to optimize and endpointing as a config detail. In my experience it is the reverse. The pipeline was the easy part.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;I ran a live A/B on staging and production on July 15:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Eager end-of-turn&lt;/td&gt;
&lt;td&gt;on (0.35)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;off&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VOICE_EOT_THRESHOLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VOICE_EOT_TIMEOUT_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cut-ins stopped. The reply now lands roughly half a second later than before, and it reads as a natural beat rather than a delay. Nobody has complained that the agent is slow. Plenty of people had complained that it interrupted.&lt;/p&gt;

&lt;p&gt;Then I rewrote the constraint in my own docs, which is the actual takeaway:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Processing p95 must stay under 1 second &lt;strong&gt;from the confirmed end of turn&lt;/strong&gt; to first audio. Listening patience is deliberate and sits &lt;strong&gt;outside&lt;/strong&gt; that budget.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two different numbers, with two different failure modes. Conflating them is how you end up optimizing the one that was already fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, with percentiles attached
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Contribution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deepgram Flux STT&lt;/td&gt;
&lt;td&gt;~350 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VAD fallback&lt;/td&gt;
&lt;td&gt;~280 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cartesia Sonic TTS&lt;/td&gt;
&lt;td&gt;~250 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total, p95&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~880 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some honesty about where that actually sits. Typical production voice deployments in 2026 land around 680 ms p50 and 1,180 ms p95. Well-optimized stacks reach sub-250 ms p50. So 880 ms p95 is solid, not remarkable, and I am quoting the tail rather than the median.&lt;/p&gt;

&lt;p&gt;I mention it because a lot of latency posts quote p50 and call it "sub-second." If you are comparing stacks, insist on the percentile. &lt;strong&gt;The p95 is what your users actually feel,&lt;/strong&gt; because the tail is exactly where a conversation breaks down. A median that looks great and a p95 that does not is an agent that feels fine in a demo and frustrating in production.&lt;/p&gt;

&lt;p&gt;One small thing that genuinely helped: chunking the first TTS request. I send the first speakable fragment to Cartesia as soon as I have 4 to 8 tokens, capped at a 250 ms wait, rather than buffering a full sentence. It costs nothing and buys a few hundred milliseconds of perceived responsiveness on every single turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug: the agent that went deaf and kept talking
&lt;/h2&gt;

&lt;p&gt;The Deepgram Flux websocket is long-lived, and it will occasionally drop abruptly mid-call. The signature in my logs was &lt;code&gt;no close frame received or sent&lt;/code&gt;. Rare, but it showed up in Sentry as a class of failure, not a one-off.&lt;/p&gt;

&lt;p&gt;My code had a &lt;code&gt;_startup_error&lt;/code&gt; latch. Its original job was reasonable: if the connect probe fails at session start, fall back to nova-3. But &lt;code&gt;run_stt&lt;/code&gt; treated that latch as &lt;strong&gt;permanent&lt;/strong&gt;. So a mid-call drop that also failed a single reconnect attempt set the latch, and the agent was deaf for the rest of the call while continuing to hold a conversation from context.&lt;/p&gt;

&lt;p&gt;Two latent hazards made it considerably worse:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The watchdog got pinned off by the failure itself.&lt;/strong&gt; If the drop happened mid-user-turn, &lt;code&gt;_flux_user_speaking&lt;/code&gt; stayed stuck true. My inactivity watchdog skips its checks while either party is speaking. So a deaf call would never time out. It would run forever, burning credits and vendor spend, with a user talking to something that had stopped listening minutes earlier.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A dead connection that reported itself healthy.&lt;/strong&gt; A non-connection error in the receive loop killed transcription while the socket object still said open. &lt;code&gt;_is_connected()&lt;/code&gt; returned true. Nothing anywhere detected it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The latch now engages &lt;strong&gt;only before the first successful connect&lt;/strong&gt;. The session-start probe and the nova-3 fallback behave exactly as before.&lt;/li&gt;
&lt;li&gt;After that, any receive-loop death or send failure marks the connection dead, and &lt;code&gt;_is_connected()&lt;/code&gt; returns false even when the socket object claims otherwise.&lt;/li&gt;
&lt;li&gt;The per-chunk &lt;code&gt;run_stt&lt;/code&gt; path retries &lt;strong&gt;for the whole call&lt;/strong&gt;, with capped backoff (0.25s, 0.5s, 1s, 2s, then 5s ceiling), each attempt bounded at 5 seconds. Audio chunks arriving inside the backoff window are dropped, so the pipeline never blocks on a connection already known to be down.&lt;/li&gt;
&lt;li&gt;Marking the connection dead force-emits &lt;code&gt;UserStoppedSpeakingFrame&lt;/code&gt;, which unpins the watchdog.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;start()&lt;/code&gt; now swallows a transient connect failure with a warning so the &lt;code&gt;StartFrame&lt;/code&gt; still propagates. Raising there left every downstream processor unstarted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Retry forever is a deliberate decision.&lt;/strong&gt; An outage of any length recovers the moment Deepgram is reachable again. An outage that never recovers gets ended by the inactivity watchdog, not by a latch. Latches are how you end up with permanently deaf calls.&lt;/p&gt;

&lt;p&gt;Logging policy matters as much as the retry logic: a warning on every attempt, exactly &lt;strong&gt;one&lt;/strong&gt; Sentry error per outage (fired on the third consecutive failure), and an info line with total outage duration on recovery. That gives you real signal on sustained outages without drowning in transient blips.&lt;/p&gt;

&lt;p&gt;Zero cost on the healthy path, because all of it only runs when &lt;code&gt;_is_connected()&lt;/code&gt; is already false. Eleven new tests, suite at 690 passing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I would tell you
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure endpointing separately from pipeline latency.&lt;/strong&gt; They are different numbers with different failure modes, and making the pipeline faster makes endpointing errors &lt;em&gt;more&lt;/em&gt; visible, not less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume every long-lived websocket in your stack will drop mid-call.&lt;/strong&gt; Make "dead" a state you can leave rather than one you latch into, and check that your watchdogs are not disabled by the very failure they exist to catch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;State the percentile. Every time.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The counterintuitive lesson underneath all three: my users never asked for a faster agent. They asked for one that would let them finish a sentence.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build real-time voice systems: Pipecat, LiveKit, Deepgram, Cartesia. The product above is &lt;a href="https://convosparr.com" rel="noopener noreferrer"&gt;ConvoSparr&lt;/a&gt;. I am currently taking on voice AI contract work, so if you are fighting latency, turn-taking, or reliability in a production voice agent, get in touch: &lt;a href="mailto:karthik@convosparr.com"&gt;karthik@convosparr.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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