<?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>Forem: Mohamed-Amine BENHIMA</title>
    <description>The latest articles on Forem by Mohamed-Amine BENHIMA (@mohamedamine_benhima).</description>
    <link>https://forem.com/mohamedamine_benhima</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3799818%2F9adb8c08-f67b-4418-81e5-d0ccdfb032e6.png</url>
      <title>Forem: Mohamed-Amine BENHIMA</title>
      <link>https://forem.com/mohamedamine_benhima</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/mohamedamine_benhima"/>
    <language>en</language>
    <item>
      <title>How WebRTC Actually Works, All in one post</title>
      <dc:creator>Mohamed-Amine BENHIMA</dc:creator>
      <pubDate>Sun, 19 Apr 2026 21:33:09 +0000</pubDate>
      <link>https://forem.com/mohamedamine_benhima/how-webrtc-actually-works-all-in-one-post-3g7o</link>
      <guid>https://forem.com/mohamedamine_benhima/how-webrtc-actually-works-all-in-one-post-3g7o</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; WebRTC is the standard protocol for real-time peer-to-peer communication. Before any audio or video flows, it does a lot of work upfront to find the best candidate pair  -  the endpoints (an IP address + port combination that a peer can be reached at, e.g. &lt;code&gt;82.10.4.1:5000&lt;/code&gt;) both peers will use to communicate  -  and this post walks you through exactly how that works, step by step.&lt;/p&gt;




&lt;p&gt;Let me ask you something. You're building a voice agent. The user speaks into their browser, your AI pipeline processes it, and the response comes back as audio. Simple, right?&lt;/p&gt;

&lt;p&gt;Now  -  how does that audio actually travel? Not "the internet," I mean specifically. How does your browser find your server? How fast? Through what route?&lt;/p&gt;

&lt;p&gt;That's what WebRTC solves. And once you understand it, a lot of real-time communication suddenly makes sense.&lt;/p&gt;




&lt;h2&gt;
  
  
  First  -  why not WebSocket or REST?
&lt;/h2&gt;

&lt;p&gt;REST is stateless. Every request is a stranger. The moment you need a continuous, live audio stream, it breaks down  -  not even an option.&lt;/p&gt;

&lt;p&gt;WebSocket is better. It keeps a persistent connection and works great for text, small messages, signaling. But for audio frames and video? It starts to feel sluggish. It wasn't designed for real-time media.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebRTC was.&lt;/strong&gt; It's the standard protocol built specifically for low-latency, real-time peer-to-peer communication  -  browser to browser, or browser to server.&lt;/p&gt;

&lt;p&gt;Browser to server: voice agents, AI avatars, streaming pipelines.&lt;br&gt;
Browser to browser: video calls, Google Meet, anything with two humans talking.&lt;/p&gt;


&lt;h2&gt;
  
  
  The core idea  -  find the best endpoint before you need it
&lt;/h2&gt;

&lt;p&gt;Here's the interesting part. WebRTC doesn't just start sending data and hope for the best.&lt;/p&gt;

&lt;p&gt;Before any audio flows, it figures out the &lt;strong&gt;best candidate pair&lt;/strong&gt;  -  the endpoints both peers will use to communicate  -  and locks it in. Think of the internet as a city with a hundred possible doors to knock on. WebRTC picks the best one upfront, then always uses it.&lt;/p&gt;

&lt;p&gt;But here's the catch. The two peers don't know each other's addresses yet. How do you find the best endpoint when you don't even know where the other person is?&lt;/p&gt;

&lt;p&gt;That's where ICE comes in.&lt;/p&gt;


&lt;h2&gt;
  
  
  ICE  -  the algorithm that makes all of this work
&lt;/h2&gt;

&lt;p&gt;ICE stands for &lt;strong&gt;Interactive Connectivity Establishment&lt;/strong&gt;. It's the algorithm both peers run to figure out their own public address and, eventually, each other's.&lt;/p&gt;

&lt;p&gt;The problem is NAT. Your browser doesn't sit directly on the internet  -  it's behind a router. That router has a public IP address, but your device has a private one (like &lt;code&gt;192.168.1.5&lt;/code&gt;). The browser doesn't know its own public address. So before two peers can connect, each one needs to discover how the outside world sees them.&lt;/p&gt;

&lt;p&gt;ICE does this by gathering &lt;strong&gt;candidates&lt;/strong&gt;  -  possible ways to reach you. There are four types.&lt;/p&gt;


&lt;h2&gt;
  
  
  The 4 ICE candidate types
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Host candidate
&lt;/h3&gt;

&lt;p&gt;The simplest one. Your local private IP address. If both peers happen to be on the same Wi-Fi network, they don't need the public internet at all  -  they just connect directly using their local IPs. Fast, zero overhead.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. STUN candidate (server-reflexive)
&lt;/h3&gt;

&lt;p&gt;When both peers are on the internet, they need to know their public IP. A &lt;strong&gt;STUN server&lt;/strong&gt; solves this  -  it's a lightweight server that receives your request, sees your public IP and port from the outside, and sends it back to you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your browser        Router (NAT)           STUN Server
  |                     |                      |
  |-- request ---------&amp;gt;|                      |
  |   (192.168.1.5)     |-- maps to public IP -&amp;gt;|
  |                     |   82.10.4.1:5000      |
  |                     |                      |
  |                     |&amp;lt;-- your public IP ----|
  |&amp;lt;-- 82.10.4.1:5000 --|                      |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you know your public address. That becomes your STUN candidate (server-reflexive: srflx).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Relay candidate (TURN)
&lt;/h3&gt;

&lt;p&gt;Some peers can't be reached directly  -  their server is behind a firewall, on a private IP for security reasons, or on a network that blocks inbound connections. In these cases, you need a &lt;strong&gt;TURN server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;TURN is the last resort  -  mainly when either peer is behind Symmetric NAT (a NAT that assigns a different external port for each unique destination, making the STUN-discovered address useless for direct connections), or when a firewall blocks all inbound connections.&lt;/p&gt;

&lt;p&gt;TURN is a relay. Instead of connecting directly, both peers connect to the TURN server, and it forwards the data between them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer A                    TURN Server              Peer B
  |                           |                       |
  |-- data (TURN public IP) -&amp;gt;|                       |
  |                           |-- relay (port → B) --&amp;gt;|
  |                           |&amp;lt;------ response -------|
  |&amp;lt;-- relay back ------------|                       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TURN is slower than a direct connection, but it's highly reliable. It's the last resort that works in almost every network  -  though extremely locked-down enterprise proxies can still block even TURN/TLS.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 STUN and TURN solve different problems. STUN tells you your public IP. TURN relays your traffic when direct connection isn't possible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. Peer-reflexive candidate (prflx)
&lt;/h3&gt;

&lt;p&gt;This one isn't gathered upfront  -  it's discovered &lt;em&gt;during&lt;/em&gt; connectivity checks. When Peer A sends a STUN ping to Peer B, that ping passes through Peer A's NAT. Peer B might see a different public address than the ones in Peer A's candidate list  -  one the NAT assigned on the fly for that specific connection. Peer B sees an address that wasn't in Peer A's candidate list and reflects it back via the STUN Binding Response (XOR-MAPPED-ADDRESS). Peer A reads that response and self-discovers its own peer-reflexive address from it. Peer A records it as a new local prflx candidate. Peer B records Peer A's source address as a new remote prflx candidate. This is uncommon in typical networks but fairly common in Symmetric NAT and corporate or mobile network scenarios  -  ICE handles it automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  How all candidates get gathered
&lt;/h2&gt;

&lt;p&gt;Both peers run this ICE gathering process simultaneously. Each peer collects all candidate types and ends up with a list like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;192.168.1.5:5000&lt;/code&gt; (host)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;82.10.4.1:5000&lt;/code&gt; (STUN / public)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;45.33.12.8:3478&lt;/code&gt; (TURN relay)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer A                  STUN Server           TURN Server
  |                          |                     |
  |-- what's my public IP? -&amp;gt;|                     |
  |&amp;lt;-- 82.10.4.1:5000 -------|                     |
  |                          |                     |
  |-- allocate relay address -------------------- &amp;gt;|
  |&amp;lt;-- relay at 45.33.12.8:3478 ------------------|
  |                          |                     |
  | now has all candidates                       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The signaling channel  -  how peers find each other first
&lt;/h2&gt;

&lt;p&gt;Here's the problem. Two peers want to connect, but they don't know each other's addresses yet. They can't send ICE candidates directly to each other  -  that would require already being connected.&lt;/p&gt;

&lt;p&gt;So they use a &lt;strong&gt;signaling channel&lt;/strong&gt;  -  a WebSocket server you build yourself, just to exchange this initial information. One persistent connection handles everything: offer, answer, and candidates as they trickle in.&lt;/p&gt;

&lt;p&gt;The SDP (Session Description Protocol) is a blob of text that describes the session  -  codecs, media types, and connection info.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer A              Signaling Server (WebSocket)             Peer B
  |                           |                                 |
  |-- ws.send(offer) --------&amp;gt;|                                 |
  |   (SDP offer)             |-- forward offer ---------------&amp;gt;|
  |                           |&amp;lt;-- SDP answer ------------------|
  |&amp;lt;-- ws.send(answer) -------|                                 |
  |                           |                                 |
  |-- ws.send(candidate) ----&amp;gt;|-- forward candidate -----------&amp;gt;| ← trickle
  |-- ws.send(candidate) ----&amp;gt;|-- forward candidate -----------&amp;gt;| ← trickle
  |-- ws.send(candidate) ----&amp;gt;|-- forward candidate -----------&amp;gt;| ← trickle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebRTC doesn't care how you implement this server  -  what matters is that the candidates reach the other peer.&lt;/p&gt;

&lt;p&gt;On the receiving end, each arriving candidate is added via &lt;code&gt;addIceCandidate()&lt;/code&gt;, which immediately triggers a connectivity check on that candidate.&lt;/p&gt;




&lt;h2&gt;
  
  
  ICE Trickle  -  don't wait, start immediately
&lt;/h2&gt;

&lt;p&gt;Gathering all candidates takes time. The host candidate is instant. STUN takes a round trip. TURN takes a bit longer.&lt;/p&gt;

&lt;p&gt;Naive approach: wait until ALL candidates are gathered, put them all in the SDP offer, then send. Slower  -  you're blocking the whole handshake on the slowest candidate (TURN).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ICE Trickle&lt;/strong&gt; (the default): the SDP offer/answer is sent once via WebSocket at the start. Then, each candidate is sent via WebSocket the moment it's discovered  -  without waiting for the others. Peer B starts connectivity checks on the first candidate while Peer A is still gathering the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer A              Signaling Server              Peer B
  |                       |                          |
  |-- ws.send(offer) -----&amp;gt;|                          |
  |   (SDP offer)          |-- forward offer --------&amp;gt;|
  |&amp;lt;-- ws.send(answer) ----|&amp;lt;-- ws.send(answer) ------|
  |                       |                          |
  |-- ws.send(candidate) -&amp;gt;|-- forward --------------&amp;gt;|
  |                       |            Peer B runs connectivity check ✓ or ✗
  |-- ws.send(candidate) -&amp;gt;|-- forward --------------&amp;gt;|
  |                       |            Peer B runs connectivity check ✓ or ✗
  |-- ws.send(candidate) -&amp;gt;|-- forward --------------&amp;gt;|
  |                       |            Peer B runs connectivity check ✓ or ✗
  |                       |                          |
  |         best working candidate wins              |
  |&amp;lt;============ WebRTC connection opens ============&amp;gt;|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How does it know if a candidate fails?&lt;/strong&gt; For each candidate received, ICE sends a small STUN "ping" through that path and waits for a response. No response after a timeout  -  that candidate is marked as failed and ignored. ICE moves on to the next one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer A                                           Peer B
  |                                                 |
  |-- STUN ping (host candidate) ------------------&amp;gt;|
  |   no response (different network)               |  ✗ fail
  |                                                 |
  |-- STUN ping (STUN candidate) -----------------&amp;gt;|
  |&amp;lt;-- response ------------------------------------|  ✓ works
  |                                                 |
  |-- STUN ping (TURN candidate) -----------------&amp;gt;|
  |&amp;lt;-- response ------------------------------------|  ✓ works
  |                                                 |
  |   pick best working pair (Highest score wins)   |
  |&amp;lt;========== connection opens ====================&amp;gt;|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Priority order is: host &amp;gt; peer-reflexive &amp;gt; server-reflexive &amp;gt; relay. the highest-priority pair that passes connectivity checks wins.&lt;/p&gt;

&lt;p&gt;ICE Trickle works because browsers fire candidates as they're found by default. But you still have to implement the &lt;code&gt;onicecandidate&lt;/code&gt; handler yourself and send each candidate through your signaling channel  -  without that code, the candidates never reach the other peer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The JavaScript side of this
&lt;/h2&gt;

&lt;p&gt;In the browser, all of this maps to &lt;code&gt;RTCPeerConnection&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss://your-signaling-server.com&lt;/span&gt;&lt;span class="dl"&gt;"&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;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;iceServers&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="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stun:stun.l.google.com:19302&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;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;turn:turn.yourserver.com:3478?transport=udp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pass&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="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// fires once per candidate found  -  each candidate represents YOU (this peer)&lt;/span&gt;
&lt;span class="c1"&gt;// sent via WebSocket to the signaling server&lt;/span&gt;
&lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onicecandidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;candidate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// event.candidate is null → gathering is complete&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Peer A: create offer → send via WebSocket&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// triggers ICE gathering&lt;/span&gt;
&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// handle incoming messages from signaling server&lt;/span&gt;
&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;answer&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="c1"&gt;// SDP answer from Peer B&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRemoteDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answer&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;candidate&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="c1"&gt;// ICE candidate from Peer B  -  triggers connectivity check immediately&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addIceCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setLocalDescription&lt;/code&gt; is the trigger. The moment you call it, ICE gathering starts in the background.&lt;/p&gt;




&lt;h2&gt;
  
  
  Transport protocols  -  how the bytes actually travel
&lt;/h2&gt;

&lt;p&gt;When TURN is involved, there's one more decision: what transport protocol does it use to relay the data?&lt;/p&gt;

&lt;p&gt;Three options, in order of preference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UDP&lt;/strong&gt;  -  fastest, no connection overhead, ideal for real-time media&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP&lt;/strong&gt;  -  fallback when UDP is blocked by a firewall&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS&lt;/strong&gt;  -  TCP with encryption, last resort, most likely to get through any firewall&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ICE prioritizes them in this order: UDP → TCP → TLS. It doesn't wait for one to fully fail before trying the next  -  it runs checks in parallel based on priority scores. UDP just wins when it works because it has the highest priority.&lt;/p&gt;

&lt;p&gt;And here's a situational optimization when connecting to a media server with a stable public IP (like LiveKit): if direct UDP to the server failed, TURN/UDP will likely fail too on the same network. In that case, skipping to TURN/TCP is reasonable. That said, there are edge cases  -  some firewalls block most UDP ports but leave UDP 443 open, so TURN/UDP can still matter. For general peer-to-peer WebRTC, don't skip TURN/UDP.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(media server scenario)&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Direct UDP      → works?  YES → connected ✓
                          NO  ↓
TURN relay UDP  → skip (UDP is blocked at firewall level  -  no point trying)
                          ↓
TURN relay TCP  → works?  YES → connected ✓
                          NO  ↓
TURN relay TLS  → connected ✓ (gets through almost any firewall)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The mesh problem  -  when 10 people are on a call
&lt;/h2&gt;

&lt;p&gt;So far, everything above assumes two peers. What happens with 10?&lt;/p&gt;

&lt;p&gt;In pure WebRTC peer-to-peer (called &lt;strong&gt;mesh topology&lt;/strong&gt;), every peer connects to every other peer directly. With 10 people, each person sends 9 streams and receives 9 streams.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;45 unique connections&lt;/strong&gt; (10×9/2). Every peer maintains all of them, and your device encodes the same audio 9 times. Bandwidth explodes. Latency goes up. This doesn't scale past 4-5 people in practice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer A --- Peer B
  |    \  /    |
  |     \/     |
  |     /\     |
  |    /  \    |
Peer C --- Peer D

Every line = a separate connection.
4 peers = 6 connections. 10 peers = 45 connections.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line is a separate connection. Every peer maintains all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  SFU  -  the fix
&lt;/h2&gt;

&lt;p&gt;The solution is a &lt;strong&gt;media server&lt;/strong&gt; running an &lt;strong&gt;SFU (Selective Forwarding Unit)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of each peer sending to every other peer, each peer sends their stream &lt;strong&gt;once&lt;/strong&gt; to the media server. The SFU then forwards it to everyone else in the room.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer A ──┐
Peer B ──┤──► SFU (Media Server) ──► Peer A
Peer C ──┤                       ──► Peer B
Peer D ──┘                       ──► Peer C
                                  ──► Peer D

Every peer uploads once. SFU handles the rest.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One upload per peer. The SFU handles distribution. It's called "selective" because it doesn't broadcast to the whole internet  -  it forwards only to the participants in that room.&lt;/p&gt;

&lt;p&gt;Participants in a room can be anything: browsers, voice agents, AI pipelines, recording servers, ingress/egress services. They all join the same room and consume from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LiveKit&lt;/strong&gt; is the popular open-source SFU. It's what most WebRTC-based voice agent infrastructure is built on.&lt;/p&gt;




&lt;h2&gt;
  
  
  ICE gathering with a media server
&lt;/h2&gt;

&lt;p&gt;With an SFU in the picture, ICE gathering works exactly the same way  -  but now the "other peer" is the &lt;strong&gt;media server&lt;/strong&gt;, not the other participants.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Peer A                     SFU (Media Server)              Peer B
  |                               |                           |
  |-- ws.send(offer) ------------&amp;gt;|                           |
  |&amp;lt;-- ws.send(answer) -----------|                           |
  |-- ws.send(candidate) --------&amp;gt;|                           |
  |&amp;lt;======= Peer A ↔ SFU connected|                           |
  |                               |                           |
  |                               |&amp;lt;-- ws.send(offer) --------|
  |                               |-- ws.send(answer) -------&amp;gt;|
  |                               |&amp;lt;-- ws.send(candidate) ----|
  |                               |======= Peer B ↔ SFU connected
  |                               |                           |
  |    Peer A and Peer B never connect directly               |
  |    all media flows through the SFU                        |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each peer runs the full ICE process  -  host, STUN, TURN candidates, offer/answer exchange  -  but only with the media server. The media server has a stable public IP (or a TURN server configured), so the handshake is simpler and faster than peer-to-peer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two different problems, two different servers
&lt;/h2&gt;

&lt;p&gt;It's easy to confuse TURN and SFU. They're not the same thing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TURN Server&lt;/th&gt;
&lt;th&gt;SFU (Media Server)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Problem it solves&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Can't reach public IP&lt;/td&gt;
&lt;td&gt;Bandwidth waste in group calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What it does&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Relays packets when direct connection fails&lt;/td&gt;
&lt;td&gt;Receives one stream, forwards to all room participants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;When you need it&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Always (for reliability)&lt;/td&gt;
&lt;td&gt;When you have more than 2-3 peers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In production, you configure &lt;strong&gt;both&lt;/strong&gt;. The media server handles routing, and it has a TURN server configured inside it for when peers can't reach it directly.&lt;/p&gt;




&lt;p&gt;That's the full picture. Two peers that don't know each other, finding the best endpoint pair to communicate through, negotiating a connection in milliseconds, and scaling to rooms of hundreds  -  all before a single byte of real audio flows.&lt;/p&gt;

&lt;p&gt;WebRTC does a lot of work so you don't have to think about it. But when something breaks  -  latency spikes, connections drop, TURN relay kicks in unexpectedly  -  knowing this flow is what tells you exactly where to look.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>realtimecommunication</category>
      <category>networking</category>
    </item>
    <item>
      <title>Is NVIDIA NIM's free tier good enough for a real-time voice agent demo?</title>
      <dc:creator>Mohamed-Amine BENHIMA</dc:creator>
      <pubDate>Sun, 08 Mar 2026 00:09:31 +0000</pubDate>
      <link>https://forem.com/mohamedamine_benhima/is-nvidia-nims-free-tier-good-enough-for-a-real-time-voice-agent-demo-2fa1</link>
      <guid>https://forem.com/mohamedamine_benhima/is-nvidia-nims-free-tier-good-enough-for-a-real-time-voice-agent-demo-2fa1</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; NVIDIA NIM gives you free hosted STT, LLM, and TTS, no credit card, 40 requests/min. Plug it into Pipecat and you have a real-time voice agent with VAD, smart turn detection, and idle reminders in a weekend. &lt;a href="https://github.com/BENHIMA-Mohamed-Amine/pipecat-demos/tree/nvidia-pipecat" rel="noopener noreferrer"&gt;Full code on GitHub&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  I wanted to test NVIDIA's AI models on a real-time voice agent
&lt;/h2&gt;

&lt;p&gt;Most voice agent tutorials start with "add your OpenAI API key." Then you blink and you've burned $20 before validating a single idea.&lt;/p&gt;

&lt;p&gt;NVIDIA NIM gives you hosted STT, LLM, and TTS, all under one API key, no credit card required, 40 requests per minute. Enough for a POC, a demo, or a weekend build.&lt;/p&gt;

&lt;p&gt;But the free tier wasn't the only reason I tried it. NVIDIA builds the GPUs everyone runs models on. They created TensorRT. So when they host their own models, I had one question: will I find a new hero, better latency, better accuracy, or both?&lt;/p&gt;

&lt;p&gt;I used Pipecat to build a full real-time voice agent and put their stack to the test. Here's what I found.&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack: NVIDIA NIM + Pipecat
&lt;/h2&gt;

&lt;p&gt;For real-time voice agents, your stack choice matters more than people think. Every service in the pipeline adds latency, STT, LLM, TTS, and they compound.&lt;/p&gt;

&lt;p&gt;NVIDIA NIM hosts optimized inference endpoints for all three. One API key, no setup, no infrastructure. The free tier gives you 40 RPM which is plenty to iterate fast and show a working demo to stakeholders.&lt;/p&gt;

&lt;p&gt;I wired it up with &lt;a href="https://github.com/pipecat-ai/pipecat" rel="noopener noreferrer"&gt;Pipecat&lt;/a&gt;, an open-source framework built specifically for real-time voice pipelines. It handles audio transport, streaming, turn detection, and pipeline orchestration, so I could focus on what actually matters: does the stack perform?&lt;/p&gt;

&lt;p&gt;The pipeline: WebRTC -&amp;gt; STT -&amp;gt; LLM -&amp;gt; TTS. Audio in, audio out, sub-second round trip is the goal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the agent
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Spin up the pipeline&lt;/strong&gt; — Wire WebRTC transport into Pipecat, connect NVIDIA STT, LLM, and TTS services. The whole pipeline is 7 lines:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;stt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_agg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;output&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;assistant_agg&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;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add VAD&lt;/strong&gt; — No mic button. Silero VAD runs locally and detects when the user starts and stops speaking automatically.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;vad_analyzer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;SileroVADAnalyzer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add SmartTurn&lt;/strong&gt; — VAD alone isn't enough. Users say "umm", "eeh", pause mid-sentence, VAD sees silence and triggers the pipeline too early. SmartTurn runs a local model that understands whether the user actually finished speaking or just paused.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;TurnAnalyzerUserTurnStopStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;turn_analyzer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;LocalSmartTurnAnalyzerV3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cpu_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mute the user on bot first speech&lt;/strong&gt; — In IVR-style flows, you want the bot to finish its greeting before the user can interrupt. &lt;code&gt;FirstSpeechUserMuteStrategy&lt;/code&gt; mutes the user's input until the bot finishes its first turn.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;user_mute_strategies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;FirstSpeechUserMuteStrategy&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add an idle reminder&lt;/strong&gt; — If the user goes silent for 60 seconds, the bot gently reminds them it's still there. One event hook, no polling.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pair.user&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;event_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_user_turn_idle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hook_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aggregator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LLMUserAggregator&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;aggregator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push_frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;LLMMessagesAppendFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The user has been idle. Gently remind them you&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;re here to help.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="n"&gt;run_llm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;h2&gt;
  
  
  What the numbers actually look like
&lt;/h2&gt;

&lt;p&gt;I went in expecting consistent results across all three services. That's not what I got.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;STT, split verdict.&lt;/strong&gt;&lt;br&gt;
The streaming STT service is fast: ~200ms average for English. Accurate enough for a production demo. But it only works for English. I tried French (&lt;code&gt;fr-FR&lt;/code&gt;) and it silently failed. After digging, including raw gRPC tests that bypassed Pipecat entirely, I found the root cause: NVIDIA's cloud truncates &lt;code&gt;"fr-FR"&lt;/code&gt; to &lt;code&gt;"fr"&lt;/code&gt; internally and fails to match a model. Not a Pipecat bug. A cloud infrastructure bug.&lt;/p&gt;

&lt;p&gt;The workaround: &lt;code&gt;NvidiaSegmentedSTTService&lt;/code&gt; with Whisper large-v3. It works for French, but it's ~1s average. That's a noticeable latency hit in a real conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTS, the hero.&lt;/strong&gt;&lt;br&gt;
Multilingual, ~400ms average, good voice quality. This one I'd use in production. Free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM, inconsistent.&lt;/strong&gt;&lt;br&gt;
Latency varied too much turn to turn. Not reliable enough for a real-time conversation where the user expects a snappy response. I wouldn't recommend it for production yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Start with English. The streaming STT at ~200ms is a completely different experience than segmented at ~1s. If your demo feels sluggish, that 800ms gap is probably why.&lt;/p&gt;

&lt;p&gt;Once the core flow is validated, swap the STT provider or self-host a model for other languages. The NIM free tier does its job, validate fast, then optimize the stack.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Full code on GitHub -&amp;gt; &lt;a href="https://github.com/BENHIMA-Mohamed-Amine/pipecat-demos/tree/nvidia-pipecat" rel="noopener noreferrer"&gt;pipecat-demos/nvidia-pipecat&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>pipecat</category>
      <category>nvidianim</category>
      <category>webrtc</category>
      <category>voiceagents</category>
    </item>
  </channel>
</rss>
