Panorama FM, or How to See all FM Stations Using SDR

Dmitrii Eliuseev
Jul 29 · 7 min read

Probably everyone, who is at least a bit interested in radio and communications, knows that using an SDR receiver it is possible to receive and process simultaneously a wide band of the radio spectrum. Displaying the waterfall in such programs as HDSDR or SDR# is not surprising. I will show how to build a pseudo-3D spectrum of the FM frequency band using RTL-SDR, GNU Radio, and about 100 lines of Python code.

Image for post
Image for post

We will also take a more powerful receiver and will look at the entire FM band 88–108MHz.

Technically, the task is quite simple. The SDR receiver digitizes the incoming radio signal using a reasonably fast ADC. At the output, we get a broadband IQ signal in the form of an array of numbers coming from the ADC with a bandwidth corresponding to the ADC sample rate. The ADC frequency determines the maximum bandwidth, that can be used. It is the same process as in a PC sound card, we only have not 22.050, but 2.000.000 or even 10.000.000 samples per second. To display the radio spectrum on the screen, we must perform the Fast Fourier Transform on the data array, it converts the data from the so-called «time domain» to the «frequency domain». Then we show the data on the screen, and the problem solved. I will also try to use the minimum of code, so GNU Radio can help us with data processing.

For tests, we first need an RTL-SDR receiver, the price of which is about $35. It allows us to receive radio signals in the 70..1700 MHz frequency range using a bandwidth of up to 2 MHz:

Image for post
Image for post

If anyone wants to do the tests using RTL-SDR, it’s recommended to get the receiver like on the photo. There are cheaper clones available, but they are of worse quality.

Well, let’s get started.

GNU Radio Processing

First, we need to get and process data from the receiver. The GNU Radio connection graph is shown in the figure:

Image for post
Image for post

As we can see, we get data from the receiver, convert the continuous datastream into a set of “vectors” of 1024 values size, perform FFT on these vectors, convert values ​​from complex to real, and finally send data via UDP. Of course, all this could be done in a pure Python using SoapySDR and numpy libraries, but the amount of code would be somewhat larger.

The QT GUI Frequency Sink block is needed only for «debugging», using this block, we can make sure that the radio stations are visible and, if necessary, adjust the receiver gain. While the application is running, the picture should look something like this:

Image for post
Image for post

If everything works, the Frequency Sink block can be disabled, also in the GNU Radio project settings, we can optionally specify «No GUI» mode and not waste UI resources. In principle, this program can be running as a service without any UI.

Rendering

Since we transmit data via UDP, we can receive it with any client, and even on another PC. I will use Python, it is quite enough for the prototype.

First, let’s get the UDP data:

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)  # UDP
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((UDP_IP, UDP_PORT))
sock.settimeout(0.5)
try:
data, addr = sock.recvfrom(fft_size * 4)
if len(data) == 4096:
udp_data = np.frombuffer(data, dtype=np.float32)
return True
except socket.timeout:
pass

Because we work with graphics, it is convenient to use the pygame library. Drawing a 3D spectrum is simple, we store the data in an array, and draw lines from top to bottom, from older to newer.

def draw_image(screen, font):
x_left, x_right, y_bottom = 0, img_size[0], img_size[1] - 5
# Draw spectrum in pseudo-3d
for d in reversed(range(depth)):
for x in range(fft_size - 1):
d_x1, d_x2, d_y1, d_y2 = x + d, x + d + 1, y_bottom - int(y_ampl*fft_data[d][x]) - y_shift - d, y_bottom - int(y_ampl*fft_data[d][x+1]) - y_shift - d
if d_y1 > y_bottom - 34: d_y1 = y_bottom - 34
if d_y2 > y_bottom - 34: d_y2 = y_bottom - 34
dim = 1 - 0.8*(d/depth)
color = int(dim*data_2_color(fft_data[d][x]))
pygame.draw.line(screen, (color//2,color,0) if d > 0 else (0, 250, 0), (d_x1, d_y1), (d_x2, d_y2), (2 if d == 0 else 1))

We can also display the frequencies and station names on the screen. The Fourier Transform algorithm gives an output of 1024 points corresponding to the bandwidth of the receiver. We know the center frequency, so calculating a pixel position can be done using an elementary school formula.

Actually that’s it, we can run both programs at the same time, and on the screen, we get a panorama showing the currently working FM stations in real-time:

Image for post
Image for post

It is easy to see that different stations have varying signal levels and we can even distinguish mono and stereo broadcasting.

Well, now I will show the promised panorama of the entire FM band. To do this, we have to put the RTL-SDR aside and use a better radio. For example, like this:

Image for post
Image for post

I use a professional grade Ettus Research SDR, but from the code perspective, everything is the same, it’s just needed to change one block to another in GNU Radio. And so it looks on the spectrum with a reception bandwidth of 24 MHz:

Image for post
Image for post

It is interesting to see the diversity of signal strength from different FM stations.

Of course, it is possible to receive not only FM stations but also any others within the operating frequencies of the SDR. For example, this is how the airband looks like:

Image for post
Image for post

We can see some permanently operating frequencies (probably the ATIS weather service) and intermittent radio communications between ground and pilots. And this is how the GSM-band spectrum looks like (the GSM-signal is wider than 24 MHz and does not fit completely):

Image for post
Image for post

Conclusion

As we can see, the study of radio spectrum is quite exciting, especially in 3D. Of course, there was no purpose here of making «another spectrum analyzer», this is just a prototype made for fun. Alas, rendering is slow, Python is not the best choice for displaying several thousand primitives on screen. Lines colouring algorithm also can be improved.

As usual, I wish all the readers successful experiments.

The full rendering source code is attached below.

# FFT
receiver_name = "RTL-SDR"
center_freq = 102.5
sample_rate = 1800000
stations = [("101.8", 101.8), ("102.1", 102.1), ("102.4", 102.4), ("102.7", 102.7), ("103.0", 103.0), ("103.2", 103.2)]# Load data from UDP
UDP_IP = "127.0.0.1"
UDP_PORT = 40868
udp_data = None
sock = None
# Panorama history
fft_size = 1024
depth = 255
fft_data = np.zeros([depth, fft_size])
# Canvas and draw
img_size = (fft_size, fft_size*9//16)
y_ampl = 90
color_ampl = 70
y_shift = 250

def udp_prepare():
global sock
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((UDP_IP, UDP_PORT))
sock.settimeout(0.5)
def udp_getdata():
global sock, udp_data
try:
data, addr = sock.recvfrom(fft_size * 4)
if len(data) == 4096:
udp_data = np.frombuffer(data, dtype=np.float32)
return True
except socket.timeout:
pass
return False
def clear_data():
for y in range(depth):
fft_data[y, :] = np.full((fft_size,), -1024)
def add_new_line():
global udp_data, fft_data
# Shift old data up
for y in reversed(range(depth - 1)):
fft_data[y + 1, :] = fft_data[y, :]
# Put new data at the bottom line
if udp_data is not None:
fft_data[0, :] = udp_data
def data_2_color(data):
c = -data + 2 # TODO: detect noise floor of the spectrum
color = 150 - int(color_ampl * c)
if color < 20:
color = 20
if color > 150:
color = 150
return color
def draw_image(screen, font):
x_left, x_right, y_bottom = 0, img_size[0], img_size[1] - 5
# Draw spectrum in pseudo-3d
for d in reversed(range(depth)):
for x in range(fft_size - 1):
d_x1, d_x2, d_y1, d_y2 = x + d, x + d + 1, y_bottom - int(y_ampl*fft_data[d][x]) - y_shift - d, y_bottom - int(y_ampl*fft_data[d][x+1]) - y_shift - d
if d_y1 > y_bottom - 34: d_y1 = y_bottom - 34
if d_y2 > y_bottom - 34: d_y2 = y_bottom - 34
dim = 1 - 0.8*(d/depth)
color = int(dim*data_2_color(fft_data[d][x]))
pygame.draw.line(screen, (color//2,color,0) if d > 0 else (0, 250, 0), (d_x1, d_y1), (d_x2, d_y2), (2 if d == 0 else 1))
# Bottom line
pygame.draw.line(screen, (0,100,0), (x_left, y_bottom - 30), (x_right, y_bottom - 30), 2)
# Station names
for st_name, freq in stations:
x_pos = fft_size*(freq - center_freq)*1000000//sample_rate
textsurface = font.render(st_name, False, (255, 255, 0))
screen.blit(textsurface, (img_size[0]//2 + x_pos - textsurface.get_width()//2, y_bottom - 22))
text_mhz = font.render("MHz", False, (255, 255, 0))
screen.blit(text_mhz, (img_size[0] - 5 - text_mhz.get_width(), y_bottom - 22))

if __name__ == "__main__":
# UI init
screen = pygame.display.set_mode(img_size)
pygame.display.set_caption(receiver_name)
pygame.font.init()
font = pygame.font.SysFont('Arial Bold', 30)
# Subscribe to UDP
clear_data()
udp_prepare()
# Main loop
is_active = True
while is_active:
# Get new data
if udp_getdata():
add_new_line()
# Update screen
screen.fill((0, 0, 0))
draw_image(screen, font)
pygame.display.flip()
# Check sys events
for events in pygame.event.get():
if events.type == QUIT:
is_active = False

Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Sign up for Best Stories

By Dev Genius

The best stories sent monthly to your email. Take a look

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

Dmitrii Eliuseev

Written by

Python and IoT Developer, science and ham radio enthusiast

Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Dmitrii Eliuseev

Written by

Python and IoT Developer, science and ham radio enthusiast

Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store