RTL-TV: An NTSC Adventure

RTL-TV: An NTSC Adventure

If you’ve ever played with software-defined radio, you’ve probably stumbled upon the magic of decoding analog signals. There’s something special about pulling a noisy, invisible broadcast out of the air and turning it into something tangible. For me, one of the most captivating projects has always been decoding analog television.

For years, the gold standard for this on Windows has been TVSharp. It’s a piece of software that takes the I/Q stream from an RTL-SDR dongle and brings a television signal to life on your screen. As a Linux and Android user, I’ve always wanted a similar tool for my own setups.

Today, I’m thrilled to announce the result of that wish: RTL-TV, a cross-platform NTSC decoder for Android and Linux, built with a nod to its spiritual predecessor.

This is the story of how we got it working.

The Go Foundation: A Solid Start

The project began with a proof-of-concept written in Go. The goal was to create a robust, command-line decoder that could run on a Linux machine. Go was a natural fit, with its excellent performance and concurrency features making it ideal for processing the high-volume data stream from an SDR.

The Go version worked beautifully. It used the gortlsdr library for direct hardware control, performed the NTSC decoding, and piped the raw RGB video frames directly to ffplay. The logic was sound, the performance was decent, and it served as a working reference for the next big step.

The Flutter Challenge: A Port to Android

The real goal was a user-friendly mobile app. I chose Flutter for its cross-platform capabilities and beautiful UI. The architecture was straightforward:

A Flutter UI for the video display and user controls.

A Dart Isolate to handle the heavy-duty signal processing in a separate thread, keeping the UI smooth.

An existing Android SDR Driver app, which my app would launch to handle the low-level hardware communication.

I carefully ported the Go decoder logic to Dart, set up the communication between the UI and the Isolate, and wrote the native “glue code” in Kotlin to launch the SDR driver. I hit “run” and was greeted with… a black screen.

The Great Gain Mystery: A Debugging Saga

This began a multi-day debugging journey that many developers will find familiar. The code looked right, but the result was wrong.

First Clue: The Signal Was Dead πŸ”‡

The first step was to add extensive logging. The logs immediately revealed the core problem: my decoder was reporting an extremely low signal level.

Levels: Max=3.11, Min=0.00, SyncThr=2.33

A Max level of 3.11 (on a scale of ~127) is just background noise. The decoder wasn’t seeing a signal at all. This was strange because the exact same transmitter, antenna, and SDR dongle worked perfectly with the Go version. The problem had to be in software. The most obvious culprit? Gain.

The Red Herring: Chasing TCP Commands πŸƒβ€β™‚οΈ

My Flutter app communicated with the SDR driver over a local TCP connection. My first assumption was that the driver was starting with a low default gain, and I needed to send commands to turn it up.

I implemented functions to send TCP commands to set the gain mode to manual and to set the gain level to a high value. I sent the commands, but the signal level remained dead. The driver logs even showed it was receiving my commands, but they seemed to have no effect. This was incredibly confusing.

The Breakthrough: Direct vs. Indirect Control πŸ’‘

The “aha!” moment came when I stepped back and compared the Go and Flutter approaches.

The Go app used a library that had direct, low-level control over the hardware.

The Flutter app had indirect control. It was just sending a command-line string (iqsrc://...) to launch a completely separate Android app.

The problem wasn’t the TCP commands after launch; the problem was the initial launch command itself.

The Smoking Gun: The Missing Argument πŸ”«

I dove into my app’s native Kotlin code (MainActivity.kt)β€”the “glue” that builds the startup command. The problem was immediately obvious:
Kotlin

// The original, buggy code
val sdrUri = Uri.parse(“iqsrc://-a 127.0.0.1 -p $port -s $samplerate”)

The code was passing the port and sample rate, but it was completely missing the -g flag for gain! The SDR driver app never received the gain setting, so it was falling back to its own internal, very low default.

The fix was to modify the Kotlin code to include the gain parameter sent from Dart. After correcting a quick typo in the IP address, the driver finally started up with the correct arguments. The moment I re-ran the app, the logs lit up.

Levels: Max=41.31, Min=0.42, SyncThr=30.98

The signal was finally getting through!

Fine-Tuning: From Lock to Stability

The screen was no longer black. I had a picture! But it was a mess of tearing, shearing vertical lines.

This is a classic sign of an unstable horizontal sync. My decoder was successfully finding the VSYNC pulses (which mark the start of a new frame), but it was struggling to align each individual horizontal line.

This problem lives in the Phase-Locked Loop (PLL), a bit of code that fine-tunes the timing for each line. The logic was a direct port from the working Go version, but the timing “jitter” introduced by the Flutter/Android event loop and TCP stream was making it over-correct.

The fix, thankfully, was simple. By making the PLL “gentler” and slower to react, it could average out the jitter and lock onto the true line timing. This was a one-line change in ntsc_decoder.dart:
Dart

// The H-Sync Phase-Locked Loop (PLL)
void _handleHSync() {
// A smaller alpha makes the PLL less reactive to jitter
const alpha = 0.01; // Was 0.05
…
}

With that change, the image snapped into place. Stable, clear, and beautiful in all its low-resolution analog glory.

RTL-TV: The Result

Today, RTL-TV is a fully functional NTSC receiver for Android and Linux. It features a simple UI with a gain slider, a manual frequency input, and a channel selector pre-loaded with common Amateur TV (ATV) frequencies.

This journey was a fantastic reminder of how a simple, overlooked detail can hide the root of a complex problem. It’s a testament to methodical debugging, clear logging, and understanding every step of the chain from the hardware to the screen.

I want to give a huge thank you to the creator of TVSharp for the inspiration. I hope RTL-TV can be as useful to the Linux and Android communities as TVSharp has been to Windows users.

You can check out the project and try it for yourself. Happy decoding!

Source: https://github.com/SarahRoseLives/rtltv
Android APK: https://sarahsforge.dev/products/rtl-tv-android

SarahroseLives Avatar

Leave a Reply

Your email address will not be published. Required fields are marked *