<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Rymi Blog</title>
    <link>https://www.rymi.live/blog</link>
    <atom:link href="https://www.rymi.live/rss.xml" rel="self" type="application/rss+xml" />
    <description>Insights on voice AI, product updates, and how teams use Rymi to automate phone calls.</description>
    <language>en-us</language>
    <lastBuildDate>Wed, 21 Oct 2026 04:55:00 GMT</lastBuildDate>
    <item>
      <title><![CDATA[Real-Time Voice AI Pipelines for Phone Calls: Streaming STT, LLM, TTS & Barge-In]]></title>
      <link>https://www.rymi.live/blog/real-time-voice-ai-pipelines-phone-calls</link>
      <guid isPermaLink="true">https://www.rymi.live/blog/real-time-voice-ai-pipelines-phone-calls</guid>
      <pubDate>Wed, 21 Oct 2026 04:55:00 GMT</pubDate>
      <description><![CDATA[How to architect streaming voice AI pipelines for phone calls: dual-signal turn detection, clause-level TTS handoff, barge-in buffer clearing, and async tools.]]></description>
      <content:encoded><![CDATA[<p>Building a voice AI prototype in a browser tab takes an afternoon. Making that same voice agent hold a natural conversation over a live telephone line is a distributed systems engineering problem.</p>
<p>The fix is a pipelined architecture where every downstream stage starts working on partial output before the upstream stage finishes. Concretely, that means wiring together five pieces end to end:</p>
<ol>
<li>Bridging narrowband PSTN/SIP telephony streams to full-duplex WebSockets.</li>
<li>Combining local audio Voice Activity Detection (VAD) with word-timing endpointing so noisy phone lines do not break turn detection.</li>
<li>Streaming LLM tokens into TTS at safe syntactic clause boundaries without clipping prosody or choking on abbreviations.</li>
<li>Executing full-stack barge-in by flushing telephony gateway playback buffers and truncating conversation history to what the caller actually heard.</li>
<li>Decoupling synchronous webhook and CRM tool calls from the audio loop.</li>
</ol>
<p>In human conversation, turn-taking is remarkably fast. A cross-linguistic study by <a href="https://pubmed.ncbi.nlm.nih.gov/19549867/">Stivers et al. (2009) in the <em>Proceedings of the National Academy of Sciences</em></a> found that across ten languages, the cross-language mean response offset was about 208 milliseconds, while the overall modal (most common) offset was 0 milliseconds, meaning the single most frequent pattern was the next speaker starting at almost the same instant the current speaker finished, not after a gap. Because preparing a single spoken word takes roughly 600 milliseconds before articulation begins (an estimate from picture-naming studies, reviewed by <a href="https://www.frontiersin.org/journals/psychology/articles/10.3389/fpsyg.2015.00731/full">Levinson and Torreira (2015) in <em>Frontiers in Psychology</em></a>), human listeners do not wait for silence to start thinking; they overlap comprehension with response formulation while the other person is still speaking.</p>
<p>In telecommunications network planning, <a href="https://www.itu.int/rec/T-REC-G.114">ITU-T Recommendation G.114</a> recommends that one-way transmission delay for general network planning should not exceed <strong>400 milliseconds</strong>, while cautioning that highly interactive tasks can be perceptibly affected by much lower delays than that ceiling.</p>
<p>When you place an AI pipeline in the middle of a phone call, every millisecond of silence after the caller stops speaking adds to that turn-taking gap. If your backend waits for Speech-to-Text (STT) to finalize a full paragraph, then waits for a Large Language Model (LLM) to generate a complete answer, and finally waits for Text-to-Speech (TTS) to render a full audio file before streaming bytes back to the carrier gateway, the caller hears dead air and assumes the call dropped.</p>
<p>At <a href="https://www.rymi.live">Rymi</a>, we build voice AI infrastructure that exposes one API for STT, LLM, TTS, and telephony, with voices for English and <a href="https://www.rymi.live/hindi-voice-agents">Hindi, including Hinglish</a>. While <a href="https://www.rymi.live/blog/what-nobody-tells-you-about-building-production-voice-ai-agents">our earlier guide on production voice AI agents</a> covered provider trade-offs and failure fallbacks, this deep dive focuses on the protocol and orchestration mechanics behind the five pieces above.</p>
<hr>
<h2>1. The Telephony-to-Model Pipeline: What Happens on a Phone Call</h2>
<p>A browser-based voice demo captures wideband microphone audio (typically 16 kHz or 48 kHz) over WebRTC. A standard public switched telephone network (PSTN) call operates under much stricter physical and protocol constraints:</p>
<ol>
<li><strong>Call Signaling (<a href="https://www.rfc-editor.org/info/rfc3261/">IETF RFC 3261</a>):</strong> Session Initiation Protocol (SIP) negotiates the call session, codecs, and media endpoints between the carrier and your Session Border Controller (SBC) or telephony provider (such as Twilio, Plivo, Vonage, or Telnyx).</li>
<li><strong>Media Transport (<a href="https://www.rfc-editor.org/info/rfc3550/">IETF RFC 3550</a>):</strong> Once the call connects, audio frames travel over the Real-time Transport Protocol (RTP), carrying sequence numbers and timestamps so the receiver&#39;s jitter buffer can reorder out-of-sequence UDP packets.</li>
<li><strong>Narrowband Audio Encoding (<a href="https://www.itu.int/rec/T-REC-G.711">ITU-T Recommendation G.711</a>):</strong> PSTN audio is typically encoded using G.711 pulse code modulation (PCM) at an <strong>8,000 Hz (8 kHz)</strong> sampling rate and 64 kbit/s bitrate, using either mu-law (<code>PCMU</code>, standard in North America and Japan) or A-law (<code>PCMA</code>, standard in India, Europe, and international routes). G.711 narrowband audio is typically limited to roughly <strong>300 to 3,400 Hz</strong>, well below the frequency range that carries some high-frequency fricative distinctions (such as <em>&quot;s&quot;</em> vs. <em>&quot;f&quot;</em>), which can make narrowband audio harder for speech recognizers trained primarily on wideband audio.</li>
<li><strong>Full-Duplex WebSocket Bridging (<a href="https://www.rfc-editor.org/info/rfc6455/">IETF RFC 6455</a>):</strong> RFC 6455 defines the WebSocket protocol&#39;s persistent, bidirectional connection and message framing. Media gateways use this transport to bridge the RTP stream to your orchestrator; the specific chunk size and control-event format, such as 20 ms audio chunks and <code>media</code>/<code>mark</code>/<code>clear</code> events, are defined by the telephony provider&#39;s media-streaming product, such as <a href="https://www.twilio.com/docs/voice/media-streams/websocket-messages">Twilio&#39;s Media Streams WebSocket messages</a>.</li>
</ol>
<p><img src="https://oplhypjwdjnpxsjfaukm.supabase.co/storage/v1/object/public/social-images/2026/10/5c0b406f-e06a-4d4a-97ad-c17a1c83093c.png" alt="Flow diagram showing a phone caller&#39;s audio passing through a carrier media gateway into an orchestrator with VAD, streaming STT, LLM, and TTS stages connected in sequence"></p>
<p><em>End-to-end telephony voice AI pipeline bridging 8 kHz G.711 SIP/RTP phone audio over a full-duplex WebSocket through local Silero VAD, streaming STT, clause-chunked LLM generation, and streaming TTS.</em></p>
<p>In a sequential architecture, total turnaround delay is the sum of the full completion time of every stage:</p>
<pre><code class="hljs language-text">Sequential turn delay = T_Endpointing + T_Full_STT + T_Full_LLM + T_Full_TTS + T_Network
</code></pre><p>In a pipelined streaming architecture, each stage emits incremental frames to the next stage before the upstream task finishes. STT transcribes while the user speaks; the LLM begins generating tokens as soon as the turn endpoint triggers; TTS begins synthesizing audio as soon as the first clause forms; and the carrier gateway plays the first G.711 audio packets while the LLM is still generating the second sentence.</p>
<pre><code class="hljs language-text">Pipelined turn delay = T_Endpointing + T_STT_Finalization + T_LLM_TTFT_First_Clause + T_TTS_TTFB + T_Network
</code></pre><p><img src="https://oplhypjwdjnpxsjfaukm.supabase.co/storage/v1/object/public/social-images/2026/10/d8f22566-5395-4f9c-9f85-c1d89d0bc604.png" alt="Bar chart comparing conversational turn-taking gaps and ITU-T G.114 delay guidance against the stage-by-stage timing of sequential versus pipelined voice AI architectures"></p>
<p><em>Conversational and ITU-T G.114 latency benchmarks compared against sequential and pipelined voice AI turn stages, using sourced millisecond thresholds and qualitative streaming labels.</em></p>
<hr>
<h2>2. Dual-Signal Turn Detection: Combining Audio VAD with Word-Timing Endpointing</h2>
<p>Determining when a caller has finished speaking is one of the hardest trade-offs in voice engineering:</p>
<ul>
<li><strong>If your endpointing threshold is too short</strong>, the agent interrupts the caller every time they pause to breathe, recall an order number, or switch languages mid-sentence.</li>
<li><strong>If your endpointing threshold is too long</strong>, every single turn suffers from an artificial silence penalty before the LLM is even invoked.</li>
</ul>
<p>Relying on a single silence timer fails on real phone calls because phone environments are noisy. Autorickshaw traffic, office chatter, ceiling fans, or carrier line hum can keep an audio-only Voice Activity Detector open indefinitely, preventing the agent from ever responding.</p>
<h3>Layer 1: Fixed-Window Acoustic VAD</h3>
<p>On the audio ingest path, lightweight neural voice activity detectors evaluate raw audio frames to distinguish human speech from stationary background noise. For example, <a href="https://github.com/snakers4/silero-vad">Silero VAD (<code>snakers4/silero-vad</code>)</a> processes audio in strict <strong>32-millisecond windows</strong>: <strong>256 samples per frame</strong> at an 8 kHz telephony sample rate (256 / 8000 = 0.032 s) or <strong>512 samples per frame</strong> at 16 kHz (512 / 16000 = 0.032 s). Running an acoustic VAD on incoming frames gives your orchestrator an immediate local signal when speech starts (<code>speech_start</code>) and when acoustic energy drops.</p>
<h3>Layer 2: Combining <code>endpointing</code> and <code>utterance_end_ms</code> in Streaming STT</h3>
<p>When streaming audio over WebSockets to a speech recognizer such as Deepgram, one workable design combines two complementary mechanisms documented in the <a href="https://developers.deepgram.com/docs/endpointing">Deepgram Streaming STT specification</a>:</p>
<ol>
<li><strong>VAD-Based Endpointing (<code>endpointing</code>):</strong> Deepgram&#39;s <code>endpointing</code> parameter monitors the incoming audio waveform for a transition from speech to silence (configurable in milliseconds; while the API default is <code>10</code> ms, conversational agents commonly configure <code>300</code> to <code>500</code> ms so brief mid-sentence pauses do not split a thought). When that silence window elapses, the recognizer locks the segment and emits a transcript response with <code>speech_final: true</code>.</li>
<li><strong>Word-Timing Gap Detection (<code>utterance_end_ms</code> + <code>interim_results</code>):</strong> Because non-speech background noise on a phone line can prevent audio VAD from detecting silence, <a href="https://developers.deepgram.com/docs/utterance-end">Deepgram&#39;s <code>utterance_end_ms</code> parameter</a> operates on <strong>word timestamps</strong> rather than raw audio silence. It requires <a href="https://developers.deepgram.com/docs/interim-results"><code>interim_results=true</code></a> and measures the time elapsed since the last <em>finalized</em> word. If no new finalized word arrives within the configured window (for example, <code>1000</code> ms), it emits an <code>{&quot;type&quot;: &quot;UtteranceEnd&quot;}</code> control event over the WebSocket, even if background noise is still present, and even if the caller resumes speaking after a brief pause, so treat it as a fallback signal rather than a guaranteed turn boundary.</li>
</ol>
<table>
<thead>
<tr>
<th align="left">Mechanism</th>
<th align="left">Signal Evaluated</th>
<th align="left">Primary Strength</th>
<th align="left">Failure Mode on Phone Calls</th>
<th align="left">Role in Pipeline</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Acoustic VAD (<code>endpointing</code>)</strong></td>
<td align="left">Raw audio waveform energy and silence</td>
<td align="left">Fast detection on quiet phone lines</td>
<td align="left">Loud background noise can keep the stream open (<code>speech_final</code> never fires)</td>
<td align="left">Fast-path turn trigger (one common design)</td>
</tr>
<tr>
<td align="left"><strong>Word-Timing Gap (<code>utterance_end_ms</code>)</strong></td>
<td align="left">Time gap since last finalized word in <a href="https://developers.deepgram.com/docs/interim-results"><code>interim_results</code></a></td>
<td align="left">Ignores non-speech background noise</td>
<td align="left">Needs a slightly longer timeout window to avoid premature firing</td>
<td align="left">Fallback turn trigger (one common design)</td>
</tr>
</tbody></table>
<p>In your orchestrator, accumulate finalized transcript segments as they arrive and use <code>speech_final: true</code> as the signal that the accumulated segments form a complete turn ready to dispatch to the LLM. A single utterance can span multiple <code>is_final: true</code> responses, so dispatching only the segment attached to the <code>speech_final</code> event can clip earlier words from the turn. Use <code>UtteranceEnd</code> as the safety net whenever <code>speech_final</code> has not fired due to line noise.</p>
<hr>
<h2>3. Clause-Boundary Token Streaming Without Breaking TTS Prosody</h2>
<p>Once the turn transcript is dispatched to the LLM via a streaming API (such as <a href="https://platform.claude.com/docs/en/build-with-claude/streaming">Anthropic&#39;s Server-Sent Events streaming messages</a> or OpenAI&#39;s streaming chat completions), tokens arrive incrementally.</p>
<p>A common mistake in early voice AI implementations is splitting the LLM token stream with a naive regular expression like <code>/[.,?!]/</code> and sending every fragment straight to TTS. In production, naive punctuation splitting causes three problems:</p>
<ol>
<li><strong>Abbreviation and numeric clipping:</strong> Splitting on every period breaks sentences in half on honorifics (<code>Dr.</code>, <code>Mr.</code>, <code>Mrs.</code>), currency and measurements (<code>Rs. 500</code>, <code>$19.99</code>, <code>3.5%</code>), or initials (<code>U.S.</code>, <code>e.g.</code>).</li>
<li><strong>Starved TTS prosody:</strong> As documented in the <a href="https://elevenlabs.io/docs/eleven-api/guides/how-to/websockets/realtime-tts">ElevenLabs WebSocket Streaming TTS reference</a>, the synthesizer buffers incoming text against a <code>chunk_length_schedule</code> (an array of cumulative character thresholds such as <code>[120, 160, 250, 290]</code>) before generating audio frames unless forced to flush. In this design, sending two-word fragments with forced flushes trades away the forward text context the model uses for pitch contour and word stress, producing more robotic, choppy intonation.</li>
<li><strong>Code-mixed punctuation:</strong> In Indian phone conversations where callers and agents speak Hinglish (Hindi-English code-mixing) or Devanagari alongside Latin script, sentence boundaries may use the Devanagari danda (<code>।</code>) in addition to standard ASCII punctuation.</li>
</ol>
<h3>Implementation: Abbreviation-Safe Clause Buffer</h3>
<p>Instead of splitting on raw punctuation characters, buffer incoming LLM tokens until both a <strong>minimum character threshold</strong> is met and a <strong>safe syntactic boundary</strong> is followed by whitespace:</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> <span class="hljs-variable constant_">COMMON_ABBREVIATIONS</span> = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Set</span>([
  <span class="hljs-string">&#x27;mr&#x27;</span>, <span class="hljs-string">&#x27;mrs&#x27;</span>, <span class="hljs-string">&#x27;ms&#x27;</span>, <span class="hljs-string">&#x27;dr&#x27;</span>, <span class="hljs-string">&#x27;prof&#x27;</span>, <span class="hljs-string">&#x27;sr&#x27;</span>, <span class="hljs-string">&#x27;jr&#x27;</span>,
  <span class="hljs-string">&#x27;rs&#x27;</span>, <span class="hljs-string">&#x27;st&#x27;</span>, <span class="hljs-string">&#x27;no&#x27;</span>, <span class="hljs-string">&#x27;vs&#x27;</span>, <span class="hljs-string">&#x27;etc&#x27;</span>, <span class="hljs-string">&#x27;eg&#x27;</span>, <span class="hljs-string">&#x27;ie&#x27;</span>,
]);

<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">ClauseChunkerOptions</span> {
  <span class="hljs-attr">minClauseChars</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">softSplitChars</span>?: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">onClause</span>: <span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">clause</span>: <span class="hljs-built_in">string</span></span>) =&gt;</span> <span class="hljs-built_in">void</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">AbbreviationSafeClauseChunker</span> {
  <span class="hljs-keyword">private</span> buffer = <span class="hljs-string">&#x27;&#x27;</span>;
  <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> <span class="hljs-attr">minClauseChars</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> <span class="hljs-attr">softSplitChars</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> <span class="hljs-attr">onClause</span>: <span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">clause</span>: <span class="hljs-built_in">string</span></span>) =&gt;</span> <span class="hljs-built_in">void</span>;

  <span class="hljs-title function_">constructor</span>(<span class="hljs-params"><span class="hljs-attr">options</span>: <span class="hljs-title class_">ClauseChunkerOptions</span></span>) {
    <span class="hljs-variable language_">this</span>.<span class="hljs-property">minClauseChars</span> = options.<span class="hljs-property">minClauseChars</span> ?? <span class="hljs-number">45</span>;
    <span class="hljs-variable language_">this</span>.<span class="hljs-property">softSplitChars</span> = options.<span class="hljs-property">softSplitChars</span> ?? <span class="hljs-number">110</span>;
    <span class="hljs-variable language_">this</span>.<span class="hljs-property">onClause</span> = options.<span class="hljs-property">onClause</span>;
  }

  <span class="hljs-keyword">public</span> <span class="hljs-title function_">pushToken</span>(<span class="hljs-attr">token</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">signal</span>?: <span class="hljs-title class_">AbortSignal</span>): <span class="hljs-built_in">void</span> {
    <span class="hljs-keyword">if</span> (signal?.<span class="hljs-property">aborted</span>) <span class="hljs-keyword">return</span>;
    <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span> += token;
    <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">extractClauses</span>(signal);
  }

  <span class="hljs-keyword">public</span> <span class="hljs-title function_">flush</span>(<span class="hljs-attr">signal</span>?: <span class="hljs-title class_">AbortSignal</span>): <span class="hljs-built_in">void</span> {
    <span class="hljs-keyword">if</span> (signal?.<span class="hljs-property">aborted</span>) {
      <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span> = <span class="hljs-string">&#x27;&#x27;</span>;
      <span class="hljs-keyword">return</span>;
    }
    <span class="hljs-keyword">const</span> remaining = <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span>.<span class="hljs-title function_">trim</span>();
    <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span> = <span class="hljs-string">&#x27;&#x27;</span>;
    <span class="hljs-keyword">if</span> (remaining.<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span>) {
      <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">onClause</span>(remaining);
    }
  }

  <span class="hljs-keyword">public</span> <span class="hljs-title function_">reset</span>(): <span class="hljs-built_in">void</span> {
    <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span> = <span class="hljs-string">&#x27;&#x27;</span>;
  }

  <span class="hljs-keyword">private</span> <span class="hljs-title function_">extractClauses</span>(<span class="hljs-attr">signal</span>?: <span class="hljs-title class_">AbortSignal</span>): <span class="hljs-built_in">void</span> {
    <span class="hljs-comment">// Match terminal punctuation (. ? ! or Devanagari danda ।) or soft clause</span>
    <span class="hljs-comment">// punctuation (, ; :) followed by whitespace</span>
    <span class="hljs-keyword">const</span> boundaryRegex = <span class="hljs-regexp">/([.?!।]|[,;:])(\s+)/g</span>;
    <span class="hljs-keyword">let</span> <span class="hljs-attr">match</span>: <span class="hljs-title class_">RegExpExecArray</span> | <span class="hljs-literal">null</span>;

    <span class="hljs-keyword">while</span> ((match = boundaryRegex.<span class="hljs-title function_">exec</span>(<span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span>)) !== <span class="hljs-literal">null</span>) {
      <span class="hljs-keyword">if</span> (signal?.<span class="hljs-property">aborted</span>) {
        <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span> = <span class="hljs-string">&#x27;&#x27;</span>;
        <span class="hljs-keyword">return</span>;
      }

      <span class="hljs-keyword">const</span> punct = match[<span class="hljs-number">1</span>];
      <span class="hljs-keyword">const</span> splitIndex = match.<span class="hljs-property">index</span> + punct.<span class="hljs-property">length</span>;
      <span class="hljs-keyword">const</span> candidate = <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span>.<span class="hljs-title function_">slice</span>(<span class="hljs-number">0</span>, splitIndex).<span class="hljs-title function_">trim</span>();

      <span class="hljs-keyword">if</span> (candidate.<span class="hljs-property">length</span> &lt; <span class="hljs-variable language_">this</span>.<span class="hljs-property">minClauseChars</span>) {
        <span class="hljs-keyword">continue</span>;
      }

      <span class="hljs-keyword">const</span> isTerminal = <span class="hljs-regexp">/[.?!।]/</span>.<span class="hljs-title function_">test</span>(punct);
      <span class="hljs-keyword">if</span> (!isTerminal &amp;&amp; candidate.<span class="hljs-property">length</span> &lt; <span class="hljs-variable language_">this</span>.<span class="hljs-property">softSplitChars</span>) {
        <span class="hljs-comment">// Hold commas/semicolons unless the clause is already long enough</span>
        <span class="hljs-keyword">continue</span>;
      }

      <span class="hljs-keyword">if</span> (punct === <span class="hljs-string">&#x27;.&#x27;</span> &amp;&amp; <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">isAbbreviationOrDecimal</span>(candidate)) {
        <span class="hljs-keyword">continue</span>;
      }

      <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">onClause</span>(candidate);
      <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span> = <span class="hljs-variable language_">this</span>.<span class="hljs-property">buffer</span>.<span class="hljs-title function_">slice</span>(splitIndex + match[<span class="hljs-number">2</span>].<span class="hljs-property">length</span>);
      boundaryRegex.<span class="hljs-property">lastIndex</span> = <span class="hljs-number">0</span>;
    }
  }

  <span class="hljs-keyword">private</span> <span class="hljs-title function_">isAbbreviationOrDecimal</span>(<span class="hljs-attr">candidate</span>: <span class="hljs-built_in">string</span>): <span class="hljs-built_in">boolean</span> {
    <span class="hljs-comment">// Decimals (e.g. 19.99, 3.5%) do not trigger splits because boundaryRegex</span>
    <span class="hljs-comment">// requires trailing whitespace. For candidate ending with a period:</span>
    <span class="hljs-comment">// Check for numbered list items / section numbers (e.g. &quot;Step 1.&quot;, &quot;2.&quot;)</span>
    <span class="hljs-keyword">if</span> (<span class="hljs-regexp">/\b\d+\.$/</span>.<span class="hljs-title function_">test</span>(candidate)) {
      <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
    }
    <span class="hljs-comment">// Check for dotted abbreviations (e.g. &quot;e.g.&quot;, &quot;i.e.&quot;, &quot;U.S.&quot;) or titles (&quot;Dr.&quot;, &quot;Rs.&quot;)</span>
    <span class="hljs-keyword">const</span> wordMatch = candidate.<span class="hljs-title function_">match</span>(<span class="hljs-regexp">/([A-Za-z.]+)\.$/</span>);
    <span class="hljs-keyword">if</span> (wordMatch) {
      <span class="hljs-keyword">const</span> normalized = wordMatch[<span class="hljs-number">1</span>].<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/\./g</span>, <span class="hljs-string">&#x27;&#x27;</span>);
      <span class="hljs-keyword">if</span> (normalized.<span class="hljs-property">length</span> === <span class="hljs-number">1</span> || <span class="hljs-variable constant_">COMMON_ABBREVIATIONS</span>.<span class="hljs-title function_">has</span>(normalized)) {
        <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
      }
    }
    <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
  }
}
</code></pre><p>This pattern lets you dispatch the opening sentence to the TTS WebSocket as soon as a complete, prosodically stable thought is ready, while longer sentences automatically split at commas or semicolons once <code>softSplitChars</code> is reached.</p>
<hr>
<h2>4. Full-Stack Barge-In: Flushing Media Gateways and Truncating Context</h2>
<p>In a natural phone call, callers interrupt the agent when they already know the answer, when the agent misunderstood their request, or when they want to skip a long explanation.</p>
<p>Most developer tutorials treat interruption handling (barge-in) as calling <code>AbortController.abort()</code> on the active LLM request. On a real phone call, <strong>aborting the LLM is only half of the job</strong>.</p>
<h3>Why Backend-Only Cancellation Fails on Phone Calls</h3>
<p>When your TTS engine synthesizes audio faster than real-time playback, it pushes several seconds of G.711 audio packets into the telephony gateway&#39;s outbound buffer. If the caller interrupts and you only cancel the LLM and TTS tasks on your server, the telephony gateway will continue playing the buffered audio for several more seconds before silence occurs.</p>
<p>Furthermore, if the LLM had already streamed three sentences before the caller interrupted during sentence one, saving all three sentences in the assistant&#39;s conversation history causes state drift: on the next turn, the LLM believes the caller heard facts in sentences two and three that were never actually played over the phone line.</p>
<h3>The Four-Step Barge-In Protocol</h3>
<p>Using bidirectional telephony WebSocket messages (such as the <code>media</code>, <code>mark</code>, and <code>clear</code> events specified in <a href="https://www.twilio.com/docs/voice/media-streams/websocket-messages">Twilio&#39;s Media Streams WebSocket documentation</a>), a production orchestrator handles barge-in across four coordinated steps:</p>
<ol>
<li><strong>Tag outbound audio segments with <code>mark</code> checkpoints:</strong> Every time you stream synthesized G.711 audio for a clause (<code>clause_0</code>, <code>clause_1</code>, <code>clause_2</code>), send a trailing <code>mark</code> message over the gateway WebSocket. The gateway echoes that <code>mark</code> event back to your server once the associated buffered media has completed playback, but note that sending a <code>clear</code> command can also cause outstanding marks to be returned immediately, so a mark echo alone does not always mean the audio played to completion.</li>
<li><strong>Filter passive backchannels from real interruptions:</strong> Callers frequently say brief acknowledgments (<em>&quot;uh-huh&quot;</em>, <em>&quot;okay&quot;</em>, <em>&quot;right&quot;</em>, or <em>&quot;haan&quot;</em> and <em>&quot;accha&quot;</em> on Indian phone calls) while listening. If your acoustic VAD mutes playback on every 100 ms sound burst, a polite listener will constantly stall the agent. Require either sustained speech energy across consecutive Silero VAD frames or a multi-word interim STT transcript before firing a full barge-in.</li>
<li><strong>Send a <code>clear</code> command to flush the gateway buffer:</strong> The instant a true interruption is confirmed, send <code>{&quot;event&quot;: &quot;clear&quot;, &quot;streamSid&quot;: &quot;...&quot;}</code> over the telephony WebSocket while triggering your local <code>AbortController</code>. The gateway immediately purges its outbound audio buffer so the phone speaker goes silent.</li>
<li><strong>Truncate assistant history to confirmed playback:</strong> When recording the interrupted assistant turn in your LLM conversation context, track each dispatched clause&#39;s state as <code>pending</code>, <code>playing</code>, <code>played</code>, or <code>cleared</code>. Include only clauses whose <code>mark</code> checkpoint was echoed back as <code>played</code> before the interruption; do not assume the clause that was actively playing when <code>clear</code> was sent was fully heard, since a <code>clear</code> command can cause the gateway to return a mark for audio it never finished playing.</li>
</ol>
<p><img src="https://oplhypjwdjnpxsjfaukm.supabase.co/storage/v1/object/public/social-images/2026/10/fc56d8f7-fa1e-4194-8c04-fc8154ebeff9.png" alt="Timeline diagram showing mark checkpoints on outbound audio, the moment an interruption is confirmed, the gateway clear command, and where conversation history is truncated"></p>
<p><em>Full-stack barge-in sequence showing outbound mark tracking, VAD and interim STT interruption confirmation, gateway buffer clearing, LLM/TTS cancellation, and conversation history truncation.</em></p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">type</span> <span class="hljs-title class_">ClauseState</span> = <span class="hljs-string">&#x27;pending&#x27;</span> | <span class="hljs-string">&#x27;playing&#x27;</span> | <span class="hljs-string">&#x27;played&#x27;</span> | <span class="hljs-string">&#x27;cleared&#x27;</span>;

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">ClauseCheckpoint</span> {
  <span class="hljs-attr">markId</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">text</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">state</span>: <span class="hljs-title class_">ClauseState</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">TurnPlaybackTracker</span> {
  <span class="hljs-keyword">private</span> <span class="hljs-attr">checkpoints</span>: <span class="hljs-title class_">ClauseCheckpoint</span>[] = [];

  <span class="hljs-keyword">public</span> <span class="hljs-title function_">registerClause</span>(<span class="hljs-attr">markId</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">text</span>: <span class="hljs-built_in">string</span>): <span class="hljs-built_in">void</span> {
    <span class="hljs-variable language_">this</span>.<span class="hljs-property">checkpoints</span>.<span class="hljs-title function_">push</span>({ markId, text, <span class="hljs-attr">state</span>: <span class="hljs-string">&#x27;pending&#x27;</span> });
  }

  <span class="hljs-keyword">public</span> <span class="hljs-title function_">onClauseDispatchedToGateway</span>(<span class="hljs-attr">markId</span>: <span class="hljs-built_in">string</span>): <span class="hljs-built_in">void</span> {
    <span class="hljs-keyword">const</span> item = <span class="hljs-variable language_">this</span>.<span class="hljs-property">checkpoints</span>.<span class="hljs-title function_">find</span>(<span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.<span class="hljs-property">markId</span> === markId);
    <span class="hljs-keyword">if</span> (item &amp;&amp; item.<span class="hljs-property">state</span> === <span class="hljs-string">&#x27;pending&#x27;</span>) {
      item.<span class="hljs-property">state</span> = <span class="hljs-string">&#x27;playing&#x27;</span>;
    }
  }

  <span class="hljs-keyword">public</span> <span class="hljs-title function_">onGatewayMarkEchoed</span>(<span class="hljs-attr">markId</span>: <span class="hljs-built_in">string</span>): <span class="hljs-built_in">void</span> {
    <span class="hljs-keyword">const</span> item = <span class="hljs-variable language_">this</span>.<span class="hljs-property">checkpoints</span>.<span class="hljs-title function_">find</span>(<span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.<span class="hljs-property">markId</span> === markId);
    <span class="hljs-comment">// A clear command can echo marks for audio that was never actually</span>
    <span class="hljs-comment">// played, so a mark already flagged `cleared` must not flip to `played`.</span>
    <span class="hljs-keyword">if</span> (item &amp;&amp; item.<span class="hljs-property">state</span> !== <span class="hljs-string">&#x27;cleared&#x27;</span>) {
      item.<span class="hljs-property">state</span> = <span class="hljs-string">&#x27;played&#x27;</span>;
    }
  }

  <span class="hljs-keyword">public</span> <span class="hljs-title function_">onGatewayCleared</span>(): <span class="hljs-built_in">void</span> {
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> item <span class="hljs-keyword">of</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">checkpoints</span>) {
      <span class="hljs-keyword">if</span> (item.<span class="hljs-property">state</span> === <span class="hljs-string">&#x27;pending&#x27;</span> || item.<span class="hljs-property">state</span> === <span class="hljs-string">&#x27;playing&#x27;</span>) {
        item.<span class="hljs-property">state</span> = <span class="hljs-string">&#x27;cleared&#x27;</span>;
      }
    }
  }

  <span class="hljs-keyword">public</span> <span class="hljs-title function_">getSpokenTextOnInterrupt</span>(): <span class="hljs-built_in">string</span> {
    <span class="hljs-comment">// Only clauses confirmed `played` are known to have reached the caller;</span>
    <span class="hljs-comment">// a clause that was merely `playing` when `clear` fired is not included</span>
    <span class="hljs-comment">// because how much of it was actually heard is unknown.</span>
    <span class="hljs-keyword">return</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">checkpoints</span>
      .<span class="hljs-title function_">filter</span>(<span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.<span class="hljs-property">state</span> === <span class="hljs-string">&#x27;played&#x27;</span>)
      .<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.<span class="hljs-property">text</span>)
      .<span class="hljs-title function_">join</span>(<span class="hljs-string">&#x27; &#x27;</span>);
  }
}
</code></pre><hr>
<h2>5. Decoupling Mid-Call Tool and Webhook Execution from the Audio Loop</h2>
<p>Voice agents on real phone calls rarely exist just to chat. They check appointment slots, verify order status, look up CRM records, or trigger payment links via external webhooks and <a href="https://developers.openai.com/api/docs/guides/function-calling">structured LLM function calling</a>.</p>
<p>If your voice orchestrator awaits a synchronous HTTP request to an external CRM or booking API inside the turn loop before emitting any audio, the caller hears silence for the entire duration of the third-party API call.</p>
<p>To keep the conversation alive during external I/O:</p>
<ol>
<li><strong>Emit an immediate verbal bridge before awaiting the tool:</strong> Configure your system prompt or tool router so that when the model emits a tool call block, the orchestrator immediately dispatches a brief, context-appropriate acknowledgment clause to TTS (<em>&quot;Let me check your booking details right now.&quot;</em>) while the HTTP request executes concurrently.</li>
<li><strong>Bind tool execution to the turn&#39;s <code>AbortSignal</code>:</strong> Callers often add extra details while the agent is looking something up (<em>&quot;Actually, check under my other phone number...&quot;</em>). Pass the turn&#39;s <code>AbortSignal</code> into <code>fetch()</code> for read-only lookups so stale requests are cancelled cleanly if the caller barges in, and use idempotency keys on state-mutating webhooks so retries never double-book an appointment.</li>
<li><strong>Enforce strict timeouts with conversational fallbacks:</strong> Never allow an external webhook to hang a phone call indefinitely. Wrap every tool invocation in a hard timeout budget so that if a downstream CRM is slow or unreachable, the orchestrator catches the timeout and instructs the agent to offer an alternative path (such as escalating to a human representative or following up via SMS).</li>
</ol>
<hr>
<h2>6. Architectural Checklist for Production Voice Pipelines</h2>
<p>Before routing live phone traffic to a voice AI pipeline, verify each stage of your stack against these engineering checks:</p>
<table>
<thead>
<tr>
<th align="left">Pipeline Layer</th>
<th align="left">Engineering Requirement</th>
<th align="left">Why It Matters on Phone Calls</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Telephony &amp; Codec</strong></td>
<td align="left">Native 8 kHz G.711 (<code>PCMU</code> / <code>PCMA</code>) handling and STT models tuned for narrowband audio, where available (<a href="https://www.itu.int/rec/T-REC-G.711">ITU-T G.711</a>)</td>
<td align="left">Helps limit accuracy loss on narrowband carrier audio and can reduce unnecessary transcoding hops</td>
</tr>
<tr>
<td align="left"><strong>Turn Detection</strong></td>
<td align="left">Dual-signal endpointing: acoustic VAD (<code>endpointing</code>) paired with word-timing gap detection (<a href="https://developers.deepgram.com/docs/utterance-end"><code>utterance_end_ms</code></a>)</td>
<td align="left">Prevents noisy phone lines from keeping the turn open forever while respecting natural pauses</td>
</tr>
<tr>
<td align="left"><strong>LLM-to-TTS Handoff</strong></td>
<td align="left">Abbreviation-safe clause chunker with minimum character thresholds</td>
<td align="left">Prevents clipped speech on <code>Dr.</code>, <code>Rs.</code>, and decimals while feeding TTS enough context for natural prosody</td>
</tr>
<tr>
<td align="left"><strong>Barge-In &amp; State</strong></td>
<td align="left">Gateway <code>clear</code> message dispatch + <code>mark</code>-based context truncation (<a href="https://www.twilio.com/docs/voice/media-streams/websocket-messages">Twilio Media Streams</a>)</td>
<td align="left">Stops phone playback immediately on interruption and keeps LLM memory synced with what the caller heard</td>
</tr>
<tr>
<td align="left"><strong>Tool &amp; Webhook I/O</strong></td>
<td align="left">Asynchronous tool execution with verbal acknowledgment and hard timeouts</td>
<td align="left">Prevents slow external CRMs or APIs from creating dead air mid-call</td>
</tr>
<tr>
<td align="left"><strong>Multilingual &amp; Code-Mixing</strong></td>
<td align="left">Support for mid-call language switching and code-mixed speech (such as Hinglish)</td>
<td align="left">Callers in multilingual markets frequently switch between English and local languages within a single call</td>
</tr>
</tbody></table>
<hr>
<h2>Building Without Wiring Every Protocol Layer Yourself</h2>
<p>Putting these pieces together from scratch means maintaining SIP/RTP media bridges, WebSocket state machines, VAD frame buffers, abbreviation-aware token chunkers, gateway <code>clear</code>/<code>mark</code> synchronization, and post-call webhook pipelines across multiple model vendors.</p>
<p>If you want to focus on your agent&#39;s logic and tools rather than maintaining the underlying voice infrastructure, <a href="https://www.rymi.live">Rymi</a> provides a unified voice AI platform behind one API for STT, LLM, TTS, and telephony, with voices for English and <a href="https://www.rymi.live/hindi-voice-agents">Hindi, including Hinglish</a>. You can bring your own carrier numbers (Twilio, Plivo, Vonage, or Telnyx) and choose between tuned managed stacks or custom bring-your-own-key stacks. <a href="https://www.rymi.live/pricing">Pricing</a> is straightforward: Managed from $0.05/min, or BYO providers at cost plus $0.02/min runtime.</p>
<p>Explore how Rymi compares to other platforms on our <a href="https://www.rymi.live/compare">comparison page</a>, read the <a href="https://docs.rymi.live">API documentation</a>, or <a href="https://www.rymi.live/signup">create a free account</a> to test an agent directly from your browser.</p>
]]></content:encoded>
      <category><![CDATA[Voice AI]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Architecture]]></category>
      <category><![CDATA[WebSockets]]></category>
    </item>
    <item>
      <title><![CDATA[What Nobody Tells You About Building Production Voice AI Agents]]></title>
      <link>https://www.rymi.live/blog/what-nobody-tells-you-about-building-production-voice-ai-agents</link>
      <guid isPermaLink="true">https://www.rymi.live/blog/what-nobody-tells-you-about-building-production-voice-ai-agents</guid>
      <pubDate>Fri, 17 Apr 2026 21:17:32 GMT</pubDate>
      <description><![CDATA[The hard part isn't the LLM. It's making five real-time systems cooperate inside a one-second window, without ever letting the caller notice.]]></description>
      <content:encoded><![CDATA[<p><em>The hard part isn&#39;t the LLM. It&#39;s making five real-time systems cooperate inside a one-second window, without ever letting the caller notice.</em></p>
<p>Building a voice AI agent sounds straightforward. You pipe audio into a speech model, feed the text to an LLM, convert the response back to audio, and play it to the caller. Simple, right?</p>
<p>It is, until you&#39;re in production and a caller is waiting. Then 200ms starts feeling like an eternity, a single transcription error cascades through your entire context window, and a TTS hiccup sounds like a frozen phone line. The LLM was always the easy part. The hard part is systems integration under real-time constraints.</p>
<p>This post covers the architectural lessons we&#39;ve accumulated building <a href="https://www.rymi.live/">Rymi</a>, a Voice AI infrastructure platform. None of this is theoretical. It&#39;s what actually breaks in production.</p>
<h2>The architecture, plainly</h2>
<p>Every production voice agent is the same five-stage pipeline:</p>
<p><strong>Phone / SIP → STT → Context + LLM → TTS → Audio out</strong></p>
<p>The problem isn&#39;t any individual component. The problem is that each of them must fit inside roughly <strong>one second total</strong>, the threshold after which a human caller notices a delay and starts doubting whether the system is working.</p>
<h2>The 1-second budget</h2>
<p>Before writing a single line of code, budget your latency like a strict expense account:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Target budget</th>
</tr>
</thead>
<tbody><tr>
<td>SIP ingestion + audio buffer</td>
<td>~200ms (fixed cost, unavoidable)</td>
</tr>
<tr>
<td>Speech-to-text</td>
<td>300–400ms</td>
</tr>
<tr>
<td>LLM inference</td>
<td>200–300ms</td>
</tr>
<tr>
<td>Text-to-speech</td>
<td>200–300ms</td>
</tr>
<tr>
<td>Network round-trips</td>
<td>100–200ms</td>
</tr>
<tr>
<td>Retry / overhead buffer</td>
<td>100–200ms</td>
</tr>
</tbody></table>
<p>Miss any one of these targets and the entire experience degrades. There&#39;s no single component to optimize. You&#39;re constantly balancing the whole system.</p>
<h2>Speech-to-text: speed beats accuracy</h2>
<p>This is counter-intuitive. You&#39;d assume you want the most accurate transcription possible. In practice, a 97%-accurate STT that takes 800ms feels worse than a 93%-accurate STT that takes 300ms. Users perceive responsiveness, not accuracy.</p>
<table>
<thead>
<tr>
<th>Provider</th>
<th>Latency</th>
<th>Accuracy</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Deepgram Nova-2 (recommended)</td>
<td>200–400ms</td>
<td>95%+</td>
<td>$0.0043/min</td>
</tr>
<tr>
<td>Google Cloud Speech</td>
<td>500–800ms</td>
<td>95%+</td>
<td>$0.006/min</td>
</tr>
<tr>
<td>Azure Cognitive Services</td>
<td>400–600ms</td>
<td>95%+</td>
<td>$0.005/min</td>
</tr>
<tr>
<td>Whisper (self-hosted)</td>
<td>800–1500ms</td>
<td>94%+</td>
<td>Compute only</td>
</tr>
</tbody></table>
<p><em>Provider figures as of April 2026, from each vendor&#39;s published documentation. Latency and pricing move; check the vendor before you commit.</em></p>
<p>For phone calls, Deepgram with streaming is currently the strongest option. It supports <code>interim_results</code> and VAD (Voice Activity Detection) events, which means you can show the caller an acknowledgment signal while waiting for the final transcript. This alone shaves 200ms off perceived latency.</p>
<p><strong>Phone audio is not regular audio.</strong> Phone calls are typically 8kHz narrowband, compressed, and acoustically degraded. Don&#39;t use a generic STT model trained on clear speech. Deepgram&#39;s &quot;phone&quot; model is specifically trained for this. The accuracy delta is significant.</p>
<h2>LLM inference: stream, don&#39;t batch</h2>
<p>Standard LLM API calls have 500ms+ latency before you receive the first token. If you wait for a complete response before starting TTS, you&#39;ve already consumed more than half your budget on one component.</p>
<p>The solution is streaming at every stage: start TTS generation the moment you have the first complete sentence from the LLM, not when the full response is done:</p>
<pre><code class="hljs language-js"><span class="hljs-comment">// Start TTS as soon as a sentence boundary arrives</span>
<span class="hljs-keyword">for</span> <span class="hljs-title function_">await</span> (<span class="hljs-keyword">const</span> chunk <span class="hljs-keyword">of</span> llmStream) {
  buffer += chunk;

  <span class="hljs-comment">// Detect sentence boundary</span>
  <span class="hljs-keyword">if</span> (<span class="hljs-regexp">/[.!?]\s*$/</span>.<span class="hljs-title function_">test</span>(buffer)) {
    ttsQueue.<span class="hljs-title function_">push</span>(buffer);
    buffer = <span class="hljs-string">&#x27;&#x27;</span>;
    <span class="hljs-keyword">if</span> (!ttsPlaying) <span class="hljs-title function_">playNextChunk</span>();
  }
}
</code></pre><p>The audio for the first sentence is already playing by the time the LLM finishes generating the second. The caller experiences a continuous response, not a loading pause.</p>
<h3>Model selection</h3>
<table>
<thead>
<tr>
<th>Model</th>
<th>Latency</th>
<th>Cost per 1M tokens</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td>Claude 3 Haiku (sweet spot)</td>
<td>300–500ms</td>
<td>$0.25 in / $1.25 out</td>
<td>Speed + quality balance</td>
</tr>
<tr>
<td>GPT-4o Mini</td>
<td>300–500ms</td>
<td>~$0.15 in / $0.60 out</td>
<td>Cost-sensitive workloads</td>
</tr>
<tr>
<td>GPT-4 Turbo</td>
<td>600–1000ms</td>
<td>$10 in / $30 out</td>
<td>Complex reasoning tasks</td>
</tr>
<tr>
<td>Mistral 7B (self-hosted)</td>
<td>50–200ms</td>
<td>Compute only</td>
<td>Privacy-critical / offline</td>
</tr>
</tbody></table>
<p><em>Model figures as of April 2026. Newer tiers have since shipped. See the <a href="/docs/guide/models">model picker</a> for what Rymi runs today.</em></p>
<p>For voice agents handling qualification, booking, or FAQ tasks, Claude 3 Haiku consistently fits the latency budget while producing natural-sounding responses. Save the heavier models for post-call analysis, not live calls.</p>
<h2>Context management: the part most teams under-build</h2>
<p>Raw transcribed text has no context. &quot;That works for me&quot; means nothing without knowing what was just discussed. You need a thin context layer that tracks conversation state, extracts entities (budget, name, phone number), and builds the prompt for each LLM call.</p>
<blockquote>
<p>A mid-tier LLM with good conversation context will outperform a frontier model with no context. Build context management before you worry about model selection.</p>
</blockquote>
<p>The design doesn&#39;t need to be complex. A simple class that tracks <code>history</code>, <code>entities</code>, and <code>state</code>, and builds a system prompt from them, handles the vast majority of real-world voice agent scenarios. Resist the urge to build a full NLU pipeline. The LLM&#39;s context window does most of that work for you.</p>
<h2>Text-to-speech: queue, don&#39;t wait</h2>
<p>TTS at high quality takes 500–1000ms for a full sentence. If you wait for the complete synthesis before playing audio, you&#39;re compounding every other latency. The pattern to use:</p>
<ul>
<li>Send the first sentence to TTS as the LLM generates it</li>
<li>Queue subsequent sentences so they&#39;re ready as the first finishes playing</li>
<li>Never wait for the full TTS response: stream audio chunks as they arrive</li>
</ul>
<p>ElevenLabs Turbo v2.5 (500–1000ms, high quality) and Google Cloud TTS (1–2s, good quality) are the two most common choices. OpenAI&#39;s TTS sits in between at $0.015/1k characters. The difference between providers matters less than whether you&#39;re streaming the output or waiting for it.</p>
<h2>Failure handling is not optional</h2>
<p>Real-time systems fail. Networks drop. STT misrecognizes. LLM calls time out. The difference between a professional voice agent and a frustrating one is whether you&#39;ve planned for these scenarios before they happen.</p>
<p>The key insight is that you have less than 100ms to decide what to do when something fails. That decision needs to be pre-planned, not reasoned about in the moment:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">processInput</span>(<span class="hljs-params">audio</span>) {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> text = <span class="hljs-keyword">await</span> <span class="hljs-title function_">timeout</span>(stt.<span class="hljs-title function_">transcribe</span>(audio), <span class="hljs-number">400</span>);
    <span class="hljs-keyword">const</span> reply = <span class="hljs-keyword">await</span> <span class="hljs-title function_">timeout</span>(llm.<span class="hljs-title function_">generate</span>(context, text), <span class="hljs-number">300</span>);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-title function_">timeout</span>(tts.<span class="hljs-title function_">synthesize</span>(reply), <span class="hljs-number">300</span>);
  } <span class="hljs-keyword">catch</span> (err) {
    <span class="hljs-keyword">if</span> (err.<span class="hljs-property">component</span> === <span class="hljs-string">&#x27;stt&#x27;</span>) <span class="hljs-keyword">return</span> tts.<span class="hljs-title function_">synthesize</span>(<span class="hljs-string">&quot;Sorry, I didn&#x27;t catch that.&quot;</span>);
    <span class="hljs-keyword">if</span> (err.<span class="hljs-property">component</span> === <span class="hljs-string">&#x27;llm&#x27;</span>) <span class="hljs-keyword">return</span> tts.<span class="hljs-title function_">synthesize</span>(<span class="hljs-string">&quot;Let me connect you with someone.&quot;</span>);
    <span class="hljs-keyword">if</span> (err.<span class="hljs-property">component</span> === <span class="hljs-string">&#x27;tts&#x27;</span>) <span class="hljs-keyword">return</span> <span class="hljs-title function_">sendSMS</span>(storedReply); <span class="hljs-comment">// fallback channel</span>
  }
}
</code></pre><h2>Five lessons from production</h2>
<ol>
<li><strong>Latency matters more than accuracy.</strong> Users judge how fast you respond, not how correct you were. Optimize for latency first. Accuracy improvements that cost latency are usually the wrong trade.</li>
<li><strong>Stream everything.</strong> Streaming every component (STT interim results, LLM tokens, TTS audio chunks) reduces perceived latency by 200-500ms without changing actual backend performance.</li>
<li><strong>Know your fallbacks before you need them.</strong> You have no time to think when a component fails. Map every failure mode to a scripted response before you ship anything to production.</li>
<li><strong>Phone audio is a different medium.</strong> 8kHz, narrowband, compressed. Models trained on clean audio behave differently. Use phone-specific STT models and test with real carrier audio, not clean microphone input.</li>
<li><strong>You can&#39;t fix what you can&#39;t measure.</strong> Track component-level latencies, STT accuracy, LLM error rate, and outcome metrics (booking rate, transfer rate) from day one. Aggregate latency alone won&#39;t tell you where to optimize.</li>
</ol>
<hr>
<h2>Closing thoughts</h2>
<p>Building production voice agents is a systems engineering problem, not an AI research problem. The LLM is genuinely the easy part now. The hard work is making five real-time services cooperate inside a one-second window, gracefully handling failure in every one of them, and maintaining enough observability to know when something is quietly degrading.</p>
<p>Start simple: Deepgram for STT, Claude Haiku or GPT-4o Mini for the LLM, ElevenLabs for TTS. Measure everything from the first call. Optimize latency before accuracy. Build fallbacks before you think you need them.</p>
<p>The goal is a voice agent that is fast, reliable, and forgettable, in the best possible way. Callers should hang up having accomplished what they called for, with no memory of anything going wrong.</p>
<p>If you&#39;re building voice agents at scale and want to skip the infrastructure work, <a href="https://www.rymi.live/">Rymi</a> provides a full voice stack via REST API: STT, LLM, TTS, barge-in, and post-call transcripts, all handled for you.</p>
]]></content:encoded>
      <category><![CDATA[Voice AI]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Production]]></category>
    </item>
  </channel>
</rss>
