<?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>TunnelRez</title>
    <link>https://tunnelrez.com/blog</link>
    <atom:link href="https://tunnelrez.com/rss.xml" rel="self" type="application/rss+xml" />
    <description>Engineering notes from TunnelRez: a PC to Android TV second monitor, in Rust on both ends.</description>
    <language>en</language>
    <lastBuildDate>Tue, 03 Nov 2026 12:00:00 GMT</lastBuildDate>
    <item>
      <title>Why we rewrote it in Rust</title>
      <link>https://tunnelrez.com/blog/why-we-rewrote-it-in-rust</link>
      <guid isPermaLink="true">https://tunnelrez.com/blog/why-we-rewrote-it-in-rust</guid>
      <pubDate>Tue, 03 Nov 2026 12:00:00 GMT</pubDate>
      <author>hello@tunnelrez.com (TunnelRez)</author>
      <category>Engineering</category>
      <description>The tools for putting a PC on a TV were thrown together and never dialed in. So we built our own path from capture to screen, in Rust on both ends, and measured every millisecond.</description>
      <content:encoded><![CDATA[<p>We wanted one simple thing. A Windows PC, an Android TV across the room, and the TV acting like a real second monitor that you can actually work on. You drag a window over, it shows up, and it feels like it&#39;s plugged in.</p>
<p>We tried the tools built for this, and they all worked in the loosest sense of the word. Latency was &quot;fine&quot;. 4K was &quot;kind of&quot;. And when a frame showed up late, nobody could tell you why, because the honest answer was that the frame went through 4 or 5 programs that were glued together and none of them was keeping score.</p>
<p>That last part is what pushed us over. You can&#39;t fix what you can&#39;t see, and you can&#39;t see anything when your hot path is someone else&#39;s black box.</p>
<h2>So we owned the whole path</h2>
<p>We rewrote it end to end, in Rust, on both sides of the Wi-Fi.</p>
<p><strong>On the PC</strong>, the sender captures the virtual monitor with Windows Graphics Capture or Desktop Duplication straight into D3D11. A small shader turns BGRA into NV12 on the GPU. Then the frame goes into the NVENC SDK through FFI we wrote by hand. There&#39;s no ffmpeg in the hot path and no copy back to the CPU. The frame leaves the GPU as HEVC, AV1 or H.264 and goes on the wire.</p>
<p><strong>On the TV</strong>, the app is native Rust too, a NativeActivity with no Java and no Kotlin. It hands the stream to the hardware decoder and puts the picture straight onto the TV&#39;s 4K video plane, with the MediaTek low-latency path switched on.</p>
<p><strong>Between them</strong> there&#39;s a protocol we can read in one sitting. A 24-byte header, one small TCP stream for video, UDP for audio, and a stats channel coming back from the TV. Every frame carries the time it was captured, so the TV can say exactly where the milliseconds went.</p>
<h2>Why Rust, really</h2>
<p>People expect a speech about memory safety here, and sure, that&#39;s part of it. The bigger reason is that Rust let us write the scary parts once and trust them.</p>
<p>A streamer like this lives on FFI. D3D11, the NVENC SDK, WASAPI on the PC, MediaCodec and AAudio on the TV. Every one of those is a C API that&#39;ll happily let you hand it a dangling pointer at 60 frames a second. In Rust, the unsafe calls sit in small wrappers that we audit, and everything above them is ordinary code that the compiler checks for us.</p>
<p>It also kept everything small. The sender is a 1.4 MB binary. The tray app is Tauri with plain HTML, CSS and JS. We said no to Electron on day 1, because shipping a whole browser to move a desktop across a room felt like a joke.</p>
<h2>How we measure</h2>
<p>Owning the path only matters if you measure it, so every real-TV run goes in a run book. It logs fps on the TV, decode time, frame gaps, GPU load and the full capture-to-screen latency, which covers capture, encode, Wi-Fi, decode and display.</p>
<p>The rule is simple. Real TV, real home network, never a fake receiver on the same PC. We learned that one the hard way, because a fake TV on your own machine makes everything look fast and tells you nothing.</p>
<p>Here&#39;s where 0.3.0 landed on our test rig, a MediaTek MT5895 Android TV and an RTX 5070 Ti laptop on home Wi-Fi, with a moving test page full screen:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Codec</th>
<th>fps on the TV</th>
<th>TV decode</th>
<th>Capture → on screen</th>
</tr>
</thead>
<tbody><tr>
<td>1080p60 · 40 Mb/s</td>
<td>HEVC</td>
<td>59.9</td>
<td>5.5 ms</td>
<td><strong>17.6 ms</strong></td>
</tr>
<tr>
<td>1080p60 · 40 Mb/s</td>
<td>AV1</td>
<td>59.9</td>
<td>7.0 ms</td>
<td>22.6 ms</td>
</tr>
<tr>
<td>4K60 · 100 Mb/s</td>
<td>HEVC</td>
<td>59.9</td>
<td>10.9 ms</td>
<td><strong>36.3 ms</strong></td>
</tr>
<tr>
<td>4K60 · 100 Mb/s</td>
<td>H.264</td>
<td>59.9</td>
<td>14.7 ms</td>
<td>43.3 ms</td>
</tr>
</tbody></table>
<p>17.6 ms from the moment Windows hands us a frame to the moment it&#39;s lit on the TV. That&#39;s about 1 frame at 60 Hz, which is the number we cared about from the start.</p>
<h2>The catch</h2>
<p>We&#39;ll keep saying this until people believe us. If your network is trash, TunnelRez isn&#39;t for you. It pushes 40 to 150 Mb/s at a fixed rate with no padding, so a crowded 2.4 GHz band or a mesh node in the attic will show up on screen, and no codec on Earth can hide that.</p>
<p>If you keep a clean 5 or 6 GHz link with power save off, or better, a cable, it&#39;s built to outrun anything else we&#39;ve tried. That&#39;s the deal, and we think it&#39;s a fair one.</p>
<p>Next up we&#39;re writing about audio, which turned out to be its own little war: <a href="/blog/chasing-audio-latency">255 ms to 55 ms</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Chasing audio latency on a TV: 255 ms to 55 ms</title>
      <link>https://tunnelrez.com/blog/chasing-audio-latency</link>
      <guid isPermaLink="true">https://tunnelrez.com/blog/chasing-audio-latency</guid>
      <pubDate>Tue, 03 Nov 2026 12:00:00 GMT</pubDate>
      <author>hello@tunnelrez.com (TunnelRez)</author>
      <category>Engineering</category>
      <description>Video was fast and the sound was a quarter second behind it. The fix took a blocking write we shouldn't have used, a crate bug that looked like an error, a TV that lies about MMAP, and one trick borrowed from OBS.</description>
      <content:encoded><![CDATA[<p>Once the picture got fast, the sound got embarrassing. You&#39;d click a video on the PC, the frame would show up on the TV in under 20 ms, and the audio would roll in about a quarter of a second later. We measured it at around 255 ms from capture to played on the TV. Lips moving, then words. On a monitor you work on that&#39;s annoying, and on a movie it&#39;s unwatchable.</p>
<p>We got it down to about 55 ms, in sync with the picture. It wasn&#39;t one fix. It was 4 separate problems stacked on top of each other, and each one was hiding the next.</p>
<h2>The setup</h2>
<p>On the PC, the sender captures what Windows plays with WASAPI loopback. It ships raw PCM, 48 kHz, 16-bit stereo, in 5 ms UDP datagrams, which is about 1.5 Mb/s. Every datagram is stamped with the same clock as the video frames, so the TV knows which sound belongs with which picture.</p>
<p>On the TV, a small jitter buffer holds the audio, between 20 and 80 ms depending on how bumpy the network is, and plays it on a clock. It never plays based on how full the buffer happens to be, because a fill-driven buffer just grows and grows. Then the audio goes out through AAudio, Android&#39;s low-latency audio API.</p>
<p>That design was right from the start. The trouble was underneath it.</p>
<h2>1. A blocking write gets the slow lane</h2>
<p>Our first TV player pushed audio into AAudio with plain blocking writes. It&#39;s the obvious way to do it, and it works, and it&#39;s slow. The Android docs say it plainly if you read far enough: a blocking <code>write()</code> never gets a FAST track, so you get high latency and big bursts no matter what you ask for.</p>
<p>So we moved to AAudio&#39;s data callback. AAudio now pulls audio from us exactly when the hardware needs it, and that&#39;s what unlocks the low-latency path. Same buffer, same clock, very different number.</p>
<h2>2. The error that wasn&#39;t an error</h2>
<p>Then the sound got choppy. Every burst or so, the stream would reopen itself and you&#39;d hear a tiny gap.</p>
<p>It turned out to be the <code>ndk</code> crate, version 0.9. It maps every non-zero AAudio result to <code>Err</code>. That&#39;s fine for most calls, but <code>write</code> and <code>set_buffer_size_in_frames</code> return a frame count when they succeed. So a perfectly good write of 240 frames came back as an error, our code did the sensible thing with an error and reopened the stream, and that happened after every burst.</p>
<p>The fix is a small helper that reads the &quot;error&quot; back as the count it really is. We pinned the crate at that exact version and wrote down why, because the day a fixed <code>ndk</code> returns <code>Ok(count)</code>, that helper has to go, and a different version of the bug would bring the choppy sound right back.</p>
<h2>3. The TV that silently says no</h2>
<p>This was the big one. AAudio can run in exclusive mode with MMAP, where the app writes straight into the hardware&#39;s buffer. That&#39;s the fastest path there is, so of course we asked for it.</p>
<p>On our MediaTek TV, exclusive MMAP always fails. In logcat you can see <code>GET_STREAM_DESCRIPTION</code> return <code>FAILED_TRANSACTION</code>. The nasty part is what happens next. AAudio doesn&#39;t fail the request. It quietly falls back to the old AudioTrack path and hands you back a working stream that just happens to be about 100 ms slower, and it never even tries shared MMAP.</p>
<p>So now the TV app checks what it actually got. If an exclusive request comes back as anything but exclusive, it closes that stream and opens a shared one on purpose. In shared mode the AAudio service makes the MMAP buffer itself and shares its endpoint, and that works on this TV. Output latency went from 107 ms to 25 ms on that change alone.</p>
<h2>4. The OBS silence trick</h2>
<p>The last problem was on the PC, and it was sneaky. Windows only delivers loopback packets while something is actually playing. When the PC goes quiet, the packets stop. When sound starts again, the first real packet shows up with a timestamp that jumps, and the TV&#39;s delay estimate jumps with it. You&#39;d get a little lurch every time audio started after a pause.</p>
<p>The fix is the same one OBS uses. For the whole stream, the sender plays digital silence to the device it&#39;s capturing. Nobody hears it, but Windows sees something playing, so loopback packets keep coming all the time, the timestamps stay one continuous line, and the audio engine never goes idle. If that silent stream can&#39;t run, the sender falls back to sending its own silent datagrams on a 5 ms timer, in the same time base.</p>
<h2>Where it landed</h2>
<p>Stack those 4 fixes together and the audio goes from about 255 ms behind to about 55 ms from capture to played on the TV, locked to the picture the TV is actually showing. The play delay follows the video, so sound and frame land together instead of racing each other.</p>
<p>We also gave it a virtual &quot;TV speaker&quot;. While you stream it becomes the Windows default, so you&#39;re capturing a clean device and your real speakers stay quiet, and when you stop, your old defaults come back.</p>
<p>What we took from it: check what the API actually gave you. AAudio handed us a slower stream without saying so, the crate reported success as an error, and we only caught both by measuring when the sound came out of the TV.</p>
<p>If you want to know how we got the picture fast in the first place, that&#39;s in <a href="/blog/why-we-rewrote-it-in-rust">Why we rewrote it in Rust</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Remote is coming: your TV, anywhere, through your own Cloudflare</title>
      <link>https://tunnelrez.com/blog/remote-is-coming</link>
      <guid isPermaLink="true">https://tunnelrez.com/blog/remote-is-coming</guid>
      <pubDate>Tue, 03 Nov 2026 12:00:00 GMT</pubDate>
      <author>hello@tunnelrez.com (TunnelRez)</author>
      <category>Roadmap</category>
      <description>TunnelRez Remote will stream your PC to a TV that isn't on your network, through a relay you deploy in your own Cloudflare account. Here's the plan, and what it won't do.</description>
      <content:encoded><![CDATA[<p>Today TunnelRez works on one network. The PC and the TV sit on the same Wi-Fi or the same cable, and that&#39;s where it&#39;s fastest. But people keep asking for the obvious next thing, which is a TV somewhere else. A parent&#39;s living room, a cabin, the office.</p>
<p>That&#39;s TunnelRez Remote. It&#39;s a written spec right now, and we haven&#39;t built it yet. We want to show you the plan before it ships, because how it&#39;s built matters more than the fact that it exists.</p>
<h2>The idea in one line</h2>
<p>You run the relay, in your own Cloudflare account. We don&#39;t run a server in the middle, and your stream never touches anything of ours.</p>
<h2>How it works</h2>
<p>The relay is a Worker template you deploy yourself, with a &quot;Deploy to Cloudflare&quot; button or a plain <code>wrangler deploy</code>. It&#39;s small, and it does 3 jobs.</p>
<ul>
<li><strong>A room.</strong> One Durable Object per owner keeps a WebSocket open for each device that&#39;s online. It uses hibernatable WebSockets, so a TV that&#39;s sitting there waiting costs you almost nothing.</li>
<li><strong>A device list.</strong> D1 holds your devices, their public keys, invites and an event log.</li>
<li><strong>TURN credentials.</strong> For networks that won&#39;t let 2 devices talk directly, it mints short-lived credentials for Cloudflare Realtime TURN for each session.</li>
</ul>
<p>The Worker only carries small JSON messages for presence and the handshake. Video and audio never go through it, because Workers can&#39;t relay UDP and we don&#39;t want your stream depending on a Worker&#39;s CPU limits anyway. Media goes over QUIC on UDP, either straight from device to device or through TURN, encrypted end to end.</p>
<h2>2 people, 2 places</h2>
<p>The setup is built for the real case, where the person at the TV and the person at the PC aren&#39;t in the same house.</p>
<p>You add a TV on your relay&#39;s admin page and it gives you a one-time invite. It&#39;s 8 characters with no look-alike letters, it lasts 24 hours and it works once. The person at the TV types the relay address and that invite with the remote, or runs one adb command if they&#39;re the type. The TV makes its own key pair, enrolls, and shows &quot;Connected to relay, waiting for a PC&quot;.</p>
<p>You add your PC the same way. Then you see the TV in the app as waiting, busy or offline, and you press Connect.</p>
<p>By default the TV asks first. It shows that a PC wants to stream to it, and the person there answers OK or No with the remote. No answer in 60 seconds means no. You can turn that off for a TV you own.</p>
<h2>The security part</h2>
<p>Keys are made on the devices and only public keys ever reach your Worker. So if someone somehow got your relay&#39;s database, they&#39;d get a list of public keys and no way into a stream. Every message to the relay and every enroll call is signed. The admin page sits behind Cloudflare Access.</p>
<h2>Picking a path</h2>
<p>There&#39;s a mode for each kind of network, and you pick it per TV.</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Path</th>
<th>When</th>
</tr>
</thead>
<tbody><tr>
<td>Auto</td>
<td>Local, then direct, then relay</td>
<td>Most people</td>
</tr>
<tr>
<td>Local</td>
<td>Same network, like today</td>
<td>Home</td>
</tr>
<tr>
<td>Direct</td>
<td>Device to device over the internet</td>
<td>Most home routers</td>
</tr>
<tr>
<td>Relay</td>
<td>Always through Cloudflare TURN</td>
<td>Hotels, offices, CGNAT on both ends</td>
</tr>
<tr>
<td>Tailscale</td>
<td>Your own tailnet</td>
<td>If you already run it</td>
</tr>
</tbody></table>
<p>Auto tells you which path it took, and the round trip it measured, on both the PC and the TV.</p>
<h2>The same warning, louder</h2>
<p>Everything we say about networks counts double here. The internet between 2 houses is the worst network you&#39;ll ever stream over, so Remote won&#39;t hit the numbers we get on a clean home link, and we&#39;re not going to pretend it will. When we have real numbers, they&#39;ll go in the run book next to the local ones, measured on a real TV like everything else.</p>
<p>We don&#39;t have a date yet. When it ships, it shows up in the <a href="/releases">release notes</a> first.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>