<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Raafat Turki</title>
    <link>https://raafat.io</link>
    <description>Technical Lead - Senior Backend Engineer</description>
    <language>en</language>
    <atom:link href="https://raafat.io/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title><![CDATA[How a video chat works]]></title>
      <link>https://raafat.io/blog/vivid/</link>
      <guid isPermaLink="true">https://raafat.io/blog/vivid/</guid>
      <description><![CDATA[I made a video chat so you don't have to]]></description>
      <content:encoded><![CDATA[<p>I built <a href="https://vivid.raafat.io">Vivid</a>, a small video chat application,
mostly because I wanted to understand what actually happens between clicking Join and seeing another person&#39;s face on the screen.</p>
<p>The source code is available <a href="https://github.com/raafatTurki/vivid">here</a>.</p>
<p>For the rest of this article I&#39;ll be walking you through what I&#39;ve learned and how video chatting works in general.</p>
<hr>
<h2>In a nutshell</h2>
<p>At first glance, a video call sounds straightforward:</p>
<ol>
<li>Get video and audio from the camera.</li>
<li>Send it to someone else.</li>
<li>Display and Play them.</li>
</ol>
<p>The first and third steps really are fairly straightforward. The second one is where almost all of the interesting problems live.</p>
<p>Browsers usually cannot simply open a connection to each other.
They&#39;re behind routers, NATs, firewalls, VPNs, corporate networks, mobile carriers,
and various other pieces of networking infrastructure that would rather not accept arbitrary incoming traffic.</p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API">WebRTC</a> solves a lot of this, but it doesn&#39;t do so with a single protocol or API.
Establishing a call involves signaling, SDP, ICE, STUN, TURN, NAT traversal, media tracks, and a surprising number of state machines.</p>
<p>Vivid is my attempt at putting those pieces together while keeping the architecture relatively small.</p>
<p>The big picture looks roughly like this:</p>
<pre><code class="hljs language-ascii">┌─────────┐   signaling   ┌───────────┐   signaling   ┌─────────┐
│  Alice  │◄─────────────►│ Go Server │◄─────────────►│   Bob   │
└────┬────┘               └───────────┘               └────┬────┘
     │                                                     │
     ├════════════════════ direct WebRTC ══════════════════┤ attempt #1
     │                                                     │
     ├══════════ STUN hole punching + direct WebRTC ═══════┤ attempt #2
     │                                                     │
     ╰══════════════ WebRTC relayed via TURN-UDP ══════════╯ attempt #3
</code></pre><p>The server helps the browsers find and negotiate with each other.</p>
<p>Once that succeeds, audio and video normally travel directly between the browsers.</p>
<p>When a direct connection isn&#39;t possible, the media can instead travel through a TURN relay:</p>
<p>Browser A ◄──► TURN ◄──► Browser B</p>
<p>That distinction between signaling and media is the useful mental model for everything that follows.</p>
<hr>
<h2>Starting with a camera and microphone</h2>
<p>Before worrying about networks, we need something to send.</p>
<p>Browsers expose cameras and microphones through <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia">navigator.mediaDevices.getUserMedia()</a>:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">const</span> stream = <span class="hljs-keyword">await</span> navigator.<span class="hljs-property">mediaDevices</span>.<span class="hljs-title function_">getUserMedia</span>({ <span class="hljs-attr">audio</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">video</span>: <span class="hljs-literal">true</span> })
</code></pre><p>The resulting <code>MediaStream</code> contains individual <code>MediaStreamTrack</code>s.</p>
<p>Usually that means something roughly like:</p>
<pre><code class="hljs language-ascii">MediaStream
├── audio track
└── video track
</code></pre><p>There are already some annoying details here.</p>
<p>Permission can be denied. The requested camera may no longer exist. Another application might be using it. Mobile devices may have multiple front and rear cameras. A device may disappear while the call is running.</p>
<p>Those problems matter when building the application, but they&#39;re not really WebRTC problems yet.</p>
<p>At this point we just have local media.</p>
<p>We still need another browser to send it to.</p>
<hr>
<h2>How does one browser find another?</h2>
<p>Suppose Alice and Bob both open Vivid and enter the same room.</p>
<p>Alice&#39;s browser doesn&#39;t know Bob exists.<br>Bob&#39;s browser doesn&#39;t know Alice exists.<br>And neither browsers initially knows how to contact the other.</p>
<p>This is where a <strong>signaling server</strong> comes in.</p>
<p>Vivid runs a small Go server that accepts WebSocket connections.
When a browser joins, it connects to the signaling server with the room ID:
<code>wss://signal.example.com/v1/ws?room=ABC123</code></p>
<p>The server assigns the client a peer ID and places it into an in-memory room.</p>
<p>Say Alice joins first then Bob joins afterwards,
The server tells Bob which peers are already there and tells Alice that Bob has joined.</p>
<p>Conceptually:</p>
<pre><code class="hljs language-ascii">Alice           Server              Bob
  │                 │                │
  │─ join room ────►│                │
  │◄────── welcome ─│                │
  │                 │                │
  │                 │◄─── join room ─│
  │                 │─ welcome ─────►│
  │◄── peer joined ─│                │
</code></pre><p>Nothing resembling a video stream has passed through the server.<br>The server is only introducing peers and relaying messages between them.<br>And that process is called <strong>signaling</strong>.</p>
<p>Interestingly, WebRTC itself doesn&#39;t specify how signaling should work.<br>You can use WebSockets, HTTP, server-sent events, Firebase, carrier pigeons encoded as JSON, or whatever else allows the two peers to exchange messages.</p>
<p>I used WebSockets because the connection is bidirectional and that&#39;s useful for some other application state.</p>
<hr>
<h2>Creating a peer connection</h2>
<p>When Alice and Bob know about the existence of each other they create an <code>RTCPeerConnection</code>
adding their local media tracks:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> track <span class="hljs-keyword">of</span> localStream.<span class="hljs-title function_">getTracks</span>()) {
  connection.<span class="hljs-title function_">addTrack</span>(track, localStream)
}
</code></pre><p>At this point we have something like the following on each browser:</p>
<pre><code class="hljs">Camera ──┐
         ├──► RTCPeerConnection
Mic ─────┘
</code></pre><p>But the two RTCPeerConnections still haven&#39;t agreed on what they&#39;re doing.<br>They need to <strong>negotiate</strong>.</p>
<hr>
<h2>Offers, answers, and SDP</h2>
<p>WebRTC uses an offer/answer negotiation model.</p>
<p>One peer creates an offer:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">const</span> offer = <span class="hljs-keyword">await</span> connection.<span class="hljs-title function_">createOffer</span>()
<span class="hljs-keyword">await</span> connection.<span class="hljs-title function_">setLocalDescription</span>(offer)
</code></pre><p>The offer contains an SDP document.<br>SDP stands for <code>Session Description Protocol</code>.</p>
<p>Despite the name, SDP isn&#39;t responsible for transporting media.
It&#39;s a description of the session the peer wants to establish:
what media exists, which codecs are supported, networking information,
and other parameters required to negotiate the connection.</p>
<p>it goes like this:</p>
<p>Alice sends her offer to Bob through the signaling server</p>
<p>Bob installs Alice&#39;s description</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">await</span> connection.<span class="hljs-title function_">setRemoteDescription</span>(offer)
</code></pre><p>Then creates an answer:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">const</span> answer = <span class="hljs-keyword">await</span> connection.<span class="hljs-title function_">createAnswer</span>()
<span class="hljs-keyword">await</span> connection.<span class="hljs-title function_">setLocalDescription</span>(answer)
</code></pre><p>And sends that answer back:</p>
<pre><code class="hljs language-ascii">Alice           Server           Bob
  │               │               │
  │─ SDP offer ──►│─ SDP offer ──►│
  │◄─ SDP answer ─│◄─ SDP answer ─│
</code></pre><p>Alice installs Bob&#39;s answer as her remote description.</p>
<p>Now both sides agree on what kind of session they&#39;re trying to establish.<br>But there is still an important unanswered question:</p>
<p><strong>Where exactly should they send the packets?</strong></p>
<hr>
<h2>Knowing Bob exists isn&#39;t the same as knowing how to reach Bob</h2>
<p>If Alice and Bob were both machines on the public internet with directly reachable IP addresses,
this problem would be considerably easier.</p>
<p>Usually they aren&#39;t.</p>
<p>A typical home network looks more like:</p>
<pre><code class="hljs language-ascii">               Internet
                  │
                 ISP
                  │
                Router
                  │
      ┌───────────┼────────────┐
      │           │            │
192.168.1.4  192.168.1.5  192.168.1.7
   Alice       Smart TV   Smart Fridge
</code></pre><p>Alice&#39;s laptop might know itself as <code>192.168.1.4</code><br>But that address is only meaningful inside Alice&#39;s local network.</p>
<p>Bob can&#39;t send packets to <code>192.168.1.4</code> and expect them to magically reach Alice.</p>
<p>The router translates traffic between private addresses and its public address.<br>This is <code>Network Address Translation</code>, or NAT.</p>
<p>There are many variations of NAT and plenty of firewall behavior layered on top of it.</p>
<p>So WebRTC needs to answer a more general question:<br><strong>Out of all the possible ways these two machines might communicate, which one actually works?</strong></p>
<p>That&#39;s what <strong>ICE</strong> is for.</p>
<hr>
<h2>ICE: finding a route between two peers</h2>
<p>ICE stands for <code>Interactive Connectivity Establishment</code>.<br>Instead of assuming a single address will work, each browser gathers multiple possible ways it might be reachable.</p>
<p>These are called ICE candidates.</p>
<p>A candidate might represent:</p>
<ul>
<li>a local network address</li>
<li>a public address discovered through STUN</li>
<li>an address provided by a TURN relay</li>
</ul>
<p>The browser gathers candidates:</p>
<pre><code class="hljs language-ascii">              ICE gathering
                   │
    ┌──────────────┼──────────────┐
    ▼              ▼              ▼
  local          STUN            TURN
    │              │              │
    ▼              ▼              ▼
  host           srflx          relay
candidate      candidate      candidate
</code></pre><p>And Vivid sends them through the signaling server:</p>
<pre><code class="hljs language-ascii">Alice               Server                 Bob
  │                    │                    │
  │─ ICE candidates ──►│─ ICE candidates ──►│
  │◄── ICE candidates ─│◄── ICE candidates ─│
</code></pre><p>Once each side has candidates from the other,
ICE tests different candidate pairs until it finds a viable path.</p>
<p>Conceptually:</p>
<p>Alice candidates                    Bob candidates</p>
<pre><code class="hljs">192.168.1.4:51321  ─────x─────  10.0.0.8:63122
203.x.x.x:43182    ─────?─────  198.x.x.x:51031
TURN relay         ─────?─────  TURN relay
</code></pre><p>The first pair that looks obvious to humans may not be usable.<br>ICE&#39;s job is to figure that out.</p>
<p>There is also a small timing problem worth handling.</p>
<p>ICE candidates can arrive before the peer has finished installing its remote SDP description.
In Vivid, those candidates are temporarily queued:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">if</span> (peer.<span class="hljs-property">connection</span>.<span class="hljs-property">remoteDescription</span>) {
  <span class="hljs-keyword">await</span> peer.<span class="hljs-property">connection</span>.<span class="hljs-title function_">addIceCandidate</span>(candidate)
} <span class="hljs-keyword">else</span> {
  peer.<span class="hljs-property">pendingCandidates</span>.<span class="hljs-title function_">push</span>(candidate)
}
</code></pre><p>After the remote description is installed, the queued candidates can be applied.</p>
<p>It&#39;s a small implementation detail, but one that demonstrates something important about WebRTC:<br><strong>many parts of negotiation happen concurrently</strong>.</p>
<hr>
<h2>STUN: what do I look like from outside?</h2>
<p>One source of ICE candidates is STUN.<br>STUN stands for <code>Session Traversal Utilities for NAT</code>.</p>
<p>A STUN server answers a fairly simple question:
<strong>What public IP address and port do you see me coming from?</strong></p>
<p>Alice sends a request to a STUN server:</p>
<pre><code class="hljs language-ascii">Alice                  Router                   STUN
  │                      │                       │
  │─────────────────────►│──────────────────────►│
  │◄─────────────────────│◄──────────────────────│
  │                      │ &quot;I see you as         │
  │                      │  203.x.x.x:43182&quot;     │
</code></pre><p>The browser can then advertise that mapping as another ICE candidate.</p>
<p>I initially hosted my own STUN server however after some testing
I switched to a public STUN server provided by cloudflare <code>stun.cloudflare.com</code>.</p>
<p>For many users, STUN is enough to establish a direct connection.</p>
<p>But not always.</p>
<hr>
<h2>TURN: when peer-to-peer isn&#39;t possible</h2>
<p>Some networks simply won&#39;t allow the two peers to establish a direct path.<br>This is common enough that a real WebRTC application can&#39;t assume STUN will always succeed.</p>
<p>That&#39;s what TURN is for.<br>TURN stands for <code>Traversal Using Relays around NAT</code></p>
<p>Instead of the peers communicating directly:<br>Alice ◄────────────────────────────────► Bob</p>
<p>a TURN server would become a relay between them:<br>Alice ◄────────────► TURN ◄────────────► Bob</p>
<p>TURN is therefore different from STUN in an important way.</p>
<p>STUN helps peers discover a route.<br>TURN becomes the route.</p>
<p>That also means TURN is considerably more expensive to operate!</p>
<p>A signaling server handles relatively small JSON messages such as:<br><code>offer</code>, <code>answer</code>, <code>candidate</code>, <code>peer-joined</code>, <code>peer-left</code> ... etc</p>
<p>A TURN server handles the actual audio and video traffic for the entire duration of a call.<br>If Alice sends a 2 Mbps video stream through TURN, those video bytes are actually passing through your TURN server.</p>
<p>For Vivid I run <code>coturn</code> (which is a TURN server written in C) alongside the signaling backend.</p>
<p>The signaling server gives each participant temporary TURN credentials rather than embedding a permanent TURN password in the frontend.</p>
<p>The username contains an expiry time and peer ID, and the credential is generated from a shared secret.</p>
<p>A simplified version looks like:</p>
<pre><code class="hljs">username = expiration + &quot;:&quot; + peerID
credential = HMAC(sharedSecret, username)
</code></pre><p>coturn knows the same shared secret and can verify the credentials.<br>This lets the browser use TURN without exposing the long-lived secret needed to generate future credentials.</p>
<hr>
<h2>The full picture</h2>
<p>Putting everything together, Say alice is already waiting in a room<br>Bob joining a Vivid call would look roughly like this:</p>
<pre><code class="hljs language-ascii">Bob              Signaling Server        Alice                 STUN              TURN
 │                    │                    │                    │                 │
 │─ join room ───────►│                    │                    │                 │
 │◄─ welcome + peers ─│                    │                    │                 │
 │                    │─ peer-joined ─────►│                    │                 │
 │─ peer-ready ──────►│───────────────────►│                    │                 │
 │                    │                    │                    │                 │
 │◄───────────────────│◄─────── SDP offer ─│                    │                 │
 │─ SDP answer ──────►│───────────────────►│                    │                 │
 │                    │                    │                    │                 │
 │                    │                    │─ STUN binding ────►│                 │
 │                    │                    │◄───── public addr ─│                 │
 │─ STUN binding ──────────────────────────────────────────────►│                 │
 │◄─────────────────────────────────────────────── public addr ─│                 │
 │                    │                    │                    │                 │
 │                    │                    │─ TURN allocate ─────────────────────►│
 │                    │                    │◄──────────────────────── relay addr ─│
 │─ TURN allocate ───────────────────────────────────────────────────────────────►│
 │◄────────────────────────────────────────────────────────────────── relay addr ─│
 │                    │                    │                    │                 │
 │◄───────────────────│◄── ICE candidates ─│                    │                 │
 │─ ICE candidates ──►│───────────────────►│                    │                 │
 │                    │                    │                    │                 │
 │◄────────────────────── ICE conn checks ─│                    │                 │
 │─ ICE conn response ────────────────────►│                    │                 │
 │─ ICE conn checks ──────────────────────►│                    │                 │
 │◄──────────────────── ICE conn response ─│                    │                 │
 │                    │                    │                    │                 │
 │                    │                    │                    │                 │
 ......................[ICE selects best working candidate pair]...................
 │                    │                    │                    │                 │
 │                    │                    │                    │                 │
 │◄═══════ WebRTC media connection ═══════►│                    │                 │
 │       (direct or relayed via TURN)      │                    │                 │
</code></pre><p>This diagram also highlights an important property of the architecture.<br>Once negotiation has completed, the signaling server is not sitting in the middle of the media connection.</p>
<p>control plane<br>Alice ◄────────► signaling server ◄────────► Bob</p>
<p>media plane<br>Alice ◄════════════════════════════════════► Bob</p>
<p>Or, if a relay is necessary:<br>Alice ◄═══════════► TURN server ◄══════════► Bob</p>
<p>Signaling and media are separate concerns.</p>
<hr>
<h2>What happens when a third person joins?</h2>
<p>Everything so far described a two-person call.<br>Vivid supports small group calls, which introduces another architectural decision.</p>
<p>There are several ways to build multiparty WebRTC calls.<br>Vivid uses the simplest one: a mesh.<br>Every participant creates a separate peer connection to every other participant.</p>
<pre><code class="hljs"> For three people:            For four:

                            ┌──►Alice◄──┐
   ┌─►Alice◄─┐              │     ▲     │
   │         │              ▼     │     ▼
   ▼         ▼             Bob◄───┼──►Carol
  Bob◄────►Carol            ▲     │     ▲
                            │     ▼     │
                            └──►David◄──┘
</code></pre><p>This architecture has a very attractive property: there is no media server to build.<br>The signaling server introduces the peers, and they establish WebRTC connections amongst themselves.<br>But the simplicity comes with a cost.</p>
<p>For <code>n</code> participants, a full mesh requires: <code>n(n-1)/2</code> p2p connections which grows quadratically.<br>So in a 1-on-1 call, there is only one connection, but in an 8-on-8 call, there are 28 connections!<br>And those numbers increase further when we add more connections per participant such as screensharing.</p>
<p>That increases the recources needed per client but the more significant problem is usually upload bandwidth.<br>Suppose Alice&#39;s outgoing camera stream is 1 Mbps.<br>With one remote participant, she sends only one copy:</p>
<p>Alice ── 1 Mbps ──► Bob</p>
<p>With three remote participants:</p>
<pre><code class="hljs">          ┌──1 Mbps──► Bob
Alice ────┼──1 Mbps──► Carol
          └──1 Mbps──► David
</code></pre><p>Alice is now uploading roughly 3 Mbps worth of video.<br>And each additional participant adds another outgoing copy.</p>
<p>That&#39;s one reason Vivid intentionally treats rooms as small and currently defaults to a maximum of 8 participants.</p>
<hr>
<h2>Why not use an SFU?</h2>
<p>Most larger video conferencing applications don&#39;t use a full mesh.
A common alternative is an SFU, or <code>Selective Forwarding Unit</code>.</p>
<p>Instead of uploading a separate stream directly to each peer, participants upload to a media server.</p>
<p>Alice can upload one stream to the SFU, and the SFU forwards it to the other participants.
That makes much larger calls practical.
It also means building or operating a media server with substantially more bandwidth and complexity than Vivid&#39;s signaling server.</p>
<p>For the sort of small calls Vivid is intended for, I preferred the mesh.<br>It&#39;s a useful example of an architectural choice being determined by the expected scale rather than one design being universally &quot;better&quot;.</p>
<p>If Vivid needed to support dozens or hundreds of participants,
moving away from the mesh would be one of the first major architectural changes required.</p>
<pre><code class="hljs language-ascii">  P2P FULL MESH                      CENTRAL SFU

                                        Alice
  ┌──►Alice◄──┐                           ▲
  │     ▲     │                           │
  ▼     │     ▼                       ┌───▼───┐
Dave◄───┼──►Carol             Dave◄──►│  SFU  │◄──►Carol
  ▲     │     ▲                       └───▲───┘
  │     ▼     │                           │
  └──► Bob◄───┘                           ▼
                                         Bob

  Users │ Links                     Users │ Links
 ───────┼───────                   ───────┼───────
      2 │ 1                             2 │ 2
      3 │ 3                             3 │ 3
      4 │ 6                             4 │ 4
      5 │ 10                            5 │ 5
      6 │ 15                            6 │ 6
      7 │ 11                            7 │ 7
      8 │ 28                            8 │ 8
</code></pre><hr>
<h2>Polite and impolite peers</h2>
<p>The nice sequence diagrams above hide one of the uglier parts of WebRTC.
They assume one peer politely creates an offer while the other patiently waits for it.
Real applications don&#39;t always behave that neatly.</p>
<p>Imagine Alice and Bob both decide they need to renegotiate at approximately the same time!<br>Now both peers have a local offer and both receive another offer.</p>
<p>This is known as glare, or an offer collision.
Vivid deals with this using the WebRTC <strong>perfect negotiation pattern</strong>.</p>
<p>Each peer connection is assigned a polite or impolite role.<br>The connection also tracks state like:</p>
<pre><code class="hljs language-json"><span class="hljs-punctuation">{</span>
  makingOffer<span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">false</span></span><span class="hljs-punctuation">,</span>
  ignoreOffer<span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">false</span></span><span class="hljs-punctuation">,</span>
  isPolite<span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span>
<span class="hljs-punctuation">}</span>
</code></pre><p>When an offer arrives, Vivid checks whether it collides with an offer the local peer is already making.</p>
<p>Roughly:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">const</span> collision = peer.<span class="hljs-property">makingOffer</span> || peer.<span class="hljs-property">connection</span>.<span class="hljs-property">signalingState</span> !== <span class="hljs-string">&quot;stable&quot;</span>
</code></pre><p>An impolite peer can ignore the colliding offer.<br>A polite peer rolls back its own negotiation and accepts the incoming one.</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">if</span> (collision) {
  <span class="hljs-keyword">await</span> connection.<span class="hljs-title function_">setLocalDescription</span>({
    <span class="hljs-attr">type</span>: <span class="hljs-string">&quot;rollback&quot;</span>,
  })
}

<span class="hljs-keyword">await</span> connection.<span class="hljs-title function_">setRemoteDescription</span>(description)
</code></pre><p>So:</p>
<pre><code class="hljs language-text">Alice changes tracks                 Bob changes tracks
        │                                   │
   createOffer()                       createOffer()
        │                                   │
        └─────────── collision ─────────────┘
                         │
           ┌─────────────┴─────────────┐
           │ polite peer rolls back    │
           │ impolite peer ignores     │
           │ one offer wins cleanly    │
           └───────────────────────────┘
</code></pre><p>Politeness is assigned deterministically from the two peer IDs:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">let</span> isPolite = selfPeerID &gt; peerID
</code></pre><p>The choice itself is arbitrary; what matters is that both peers reach opposite conclusions consistently</p>
<p>This isn&#39;t particularly visible to someone using Vivid. Which is exactly the point.<br>A lot of networking code exists to make race conditions that absolutely do happen look like they never happened.</p>
<hr>
<h2>Audio &amp; Noise Suppression</h2>
<p>For noise suppression, Vivid uses RNNoise compiled to WebAssembly running inside an AudioWorklet on the browser.</p>
<p><a href="https://meet.jit.si">Jitsi</a> thanklessly published their <a href="https://github.com/jitsi/rnnoise-wasm">own port</a> of RNNoise which I used at first.<br>I later switched to <a href="https://www.npmjs.com/package/@timephy/rnnoise-wasm">@timephy/rnnoise-wasm</a>,
which is a fork which upgrades RNNoise to 0.2 and adds an <code>AudioWorkletNode</code>.</p>
<p>Plugging it looks something like:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">const</span> context = <span class="hljs-keyword">new</span> <span class="hljs-title class_">AudioContextClass</span>({ <span class="hljs-attr">sampleRate</span>: <span class="hljs-number">48000</span> })
<span class="hljs-keyword">await</span> context.<span class="hljs-property">audioWorklet</span>.<span class="hljs-title function_">addModule</span>(<span class="hljs-title class_">NoiseSuppressorWorklet</span>)
<span class="hljs-keyword">const</span> source = context.<span class="hljs-title function_">createMediaStreamSource</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">MediaStream</span>([track]))

<span class="hljs-keyword">const</span> processor = <span class="hljs-keyword">new</span> <span class="hljs-title class_">AudioWorkletNode</span>(context, <span class="hljs-title class_">NoiseSuppressorWorklet</span>_Name, { <span class="hljs-attr">channelCount</span>: <span class="hljs-number">1</span> })

source.<span class="hljs-title function_">connect</span>(processor)
processor.<span class="hljs-title function_">connect</span>(merger, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>)
processor.<span class="hljs-title function_">connect</span>(merger, <span class="hljs-number">0</span>, <span class="hljs-number">1</span>)
merger.<span class="hljs-title function_">connect</span>(destination)

<span class="hljs-keyword">const</span> processedTrack = destination.<span class="hljs-property">stream</span>.<span class="hljs-title function_">getAudioTracks</span>()[<span class="hljs-number">0</span>]
</code></pre><p>Which would be added as another step in the audio processing pipeline:</p>
<pre><code class="hljs language-ascii">    Alice
      │
Physical microphone
      │
getUserMedia()
      │
48 kHz AudioContext
      │
RNNoise WASM AudioWorkletNode
      │
ChannelMergerNode (mono ─► left + right)
      │
processed MediaStreamTrack
      │
Web Audio mixer ◄── audio from screen sharing (chromium single-tab audio only)
      │
      ├────► RTCRtpSender for Bob
      ├────► RTCRtpSender for David
      └────► RTCRtpSender for ...
</code></pre><p>Because the processing happens client-side, the signaling server and remote peers don&#39;t need to know anything about RNNoise.<br>If WASM/audio processing isn&#39;t available, Vivid falls back to sending the original microphone track instead.</p>
<hr>
<h2>Screen sharing is just another media track</h2>
<p>Once the peer connection machinery exists, features like screen sharing are less mysterious.</p>
<p>The browser provides another media stream through:</p>
<pre><code class="hljs language-js">navigator.<span class="hljs-property">mediaDevices</span>.<span class="hljs-title function_">getDisplayMedia</span>()
</code></pre><p>That produces another video track. Then vivid can attach that track to the existing peer connections and renegotiate where necessary.</p>
<p>Conceptually:</p>
<pre><code class="hljs language-ascii">Camera Track ─────────────┐
Microphone Track ─────────┼──► PeerConnection
ScreenShare Audio Track ──┤
ScreenShare Video Track ──┘
</code></pre><p>The same underlying WebRTC connection isn&#39;t limited to one camera stream. It&#39;s transporting media tracks.</p>
<p>That distinction becomes useful when implementing things such as camera switching, screen sharing, muting, and audio processing.</p>
<hr>
<h2>Chat: Not everything needs WebRTC</h2>
<p>Vivid also has text chat.
My first instinct when I thought about implementing it was that chat should naturally use an <code>RTCDataChannel</code>.</p>
<p>WebRTC data channels let peers exchange arbitrary application data over the peer connection, so they would certainly work.<br>Vivid doesn&#39;t use one.</p>
<p>The application already needs a persistent WebSocket connection for signaling.
Chat messages are tiny compared with audio and video,
so sending them through the existing signaling infrastructure is much simpler.</p>
<p>Alice ── chat ──► signaling server ── chat ──► Bob</p>
<p>The server keeps a small in-memory history for each active room and sends it to participants when they join.<br>When the last participant leaves, the room disappears and its chat history goes with it.<br>That happens to fit the semantics I wanted for Vivid: rooms are ephemeral.</p>
<p>It also illustrates a useful engineering lesson from the project:</p>
<p>using WebRTC doesn&#39;t mean everything in a video chat application should use WebRTC.</p>
<p>Use it where it solves a problem.</p>
<hr>
<h2>The server is boring</h2>
<p>Before building Vivid, &quot;video chat backend&quot; sounded like something that would necessarily involve receiving, processing, and redistributing video streams.
For this architecture, it doesn&#39;t.</p>
<p>The signaling backend mostly needs to know rooms have peers and how to relay signaling messages between them.</p>
<p>That keeps the Go server fairly boring.<br>And boring servers are often good servers.</p>
<hr>
<h2>Deployment</h2>
<p>Vivid is deployed as three separate Docker services:</p>
<pre><code class="hljs">vivid-web       Svelte frontend
vivid-backend   Go signaling server
vivid-coturn    TURN relay
</code></pre><p>The web frontend and Go signaling server sit behind the external proxy network,
while coturn uses the host network because TURN needs direct access to its UDP relay ports. STUN/TURN configuration and the shared TURN authentication secret are injected through environment variables.</p>
<p>Deployment itself is intentionally small: the server updates the Git checkout and runs <code>docker compose up -d --build</code>, rebuilding and restarting the services from the latest source.</p>
<hr>
<h2>A future consideration</h2>
<p>Vivid currently uses TURN over UDP, which is generally the preferred path because real-time audio and video benefit from avoiding TCP&#39;s retransmission behavior and head-of-line blocking.</p>
<p>The downside is that some restrictive corporate, hotel, university, or public networks block outbound UDP entirely. In those environments, a perfectly functional TURN server may still be unreachable.</p>
<p>A future improvement would be to expose TURN over TLS, typically on port <code>:443</code><br>Using port <code>:443</code> makes TURN traffic look much more like ordinary HTTPS traffic from the network&#39;s point of view,
Which gives it a better chance of passing through restrictive firewalls.</p>
<p>Supporting this would require enabling TLS on coturn,
providing a certificate for the TURN hostname, and advertising additional turn URLs in Vivid&#39;s ICE server configuration.</p>
<p>It adds some deployment and certificate-management complexity,
but would make Vivid considerably more reliable on networks where UDP traffic is blocked</p>
<hr>
<h2>Closing</h2>
<p>Thanks for reading.<br>I hope this made some of the moving parts behind video chats, WebRTC, and Vivid a little clearer.</p>
<p>Which was ultimately the point of the project: not just to build a video chat, but to understand why each piece is there.</p>
<p><em>Box-drawing diagrams created with <a href="https://asciiflow.com">asciiflow.com</a>.</em></p>
<p>— <strong>Raafat</strong></p>
]]></content:encoded>
      <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>