Наверное, каждый, кто хоть немного интересуется радио и связью, знает, что с помощью SDR-приемника можно одновременно принимать и обрабатывать широкий диапазон радиочастотного спектра. Отображение водопада в таких программах как HDSDR или SDR # неудивительно. Я покажу, как построить псевдо-3D спектр частотного диапазона FM, используя RTL-SDR, GNU Radio и около 100 строк кода Python.
Также возьмем более мощный приемник и рассмотрим весь FM диапазон 88–108 МГц.
Технически задача довольно простая. Приемник SDR оцифровывает входящий радиосигнал с помощью достаточно быстрого АЦП. На выходе мы получаем широкополосный IQ-сигнал в виде массива чисел, поступающий от АЦП с полосой пропускания, соответствующей частоте дискретизации АЦП. Частота АЦП определяет максимальную полосу пропускания, которую можно использовать. Это тот же процесс, что и в звуковой карте ПК, только не 22.050, а 2.000.000 или даже 10.000.000 отсчетов в секунду. Чтобы отобразить радиоспектр на экране, мы должны выполнить быстрое преобразование Фурье для массива данных, оно преобразует данные из так называемой «временной области» в «частотную область». Затем показываем данные на экране, и проблема решается. Я также постараюсь использовать минимум кода, чтобы GNU Radio могло помочь нам с обработкой данных.
Для тестов нам сначала понадобится приемник RTL-SDR, цена которого около 35 долларов. Это позволяет нам принимать радиосигналы в диапазоне частот 70..1700 МГц, используя полосу пропускания до 2 МГц:
Если кто-то хочет провести тесты с использованием RTL-SDR, рекомендуется получить приемник как на фото. Доступны более дешевые клоны, но они худшего качества.
Что ж, приступим.
Обработка радио GNU
Во-первых, нам нужно получить и обработать данные от приемника. График подключения GNU Radio показан на рисунке:
Как мы видим, мы получаем данные от приемника, конвертируем непрерывный поток данных в набор «векторов» размером 1024 значения, выполняем БПФ для этих векторов, преобразуем значения из комплексных в реальные и, наконец, отправляем данные через UDP. Конечно, все это можно сделать на чистом Python с использованием библиотек SoapySDR и numpy, но объем кода будет несколько больше.
Блок QT GUI Frequency Sink нужен только для «отладки», с помощью этого блока мы можем убедиться, что радиостанции видны, и при необходимости отрегулировать усиление приемника. Во время работы приложения картинка должна выглядеть примерно так:
Если все работает, блок Frequency Sink можно отключить, также в настройках проекта GNU Radio мы можем дополнительно указать режим «No GUI» и не тратить ресурсы пользовательского интерфейса. В принципе, эта программа может работать как служба без какого-либо пользовательского интерфейса.
Рендеринг
Поскольку мы передаем данные через UDP, мы можем получать их с любого клиента и даже на другом ПК. Буду использовать Python, для прототипа этого вполне достаточно.
Во-первых, давайте получим данные UDP:
fft_size = 1024 udp_data = None UDP_IP = "127.0.0.1" UDP_PORT = 40868
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
Поскольку мы работаем с графикой, удобно использовать библиотеку pygame. Нарисовать трехмерный спектр просто: мы сохраняем данные в массиве и рисуем линии сверху вниз, от старых к новым.
fft_size = 1024 depth = 255 fft_data = np.zeros([depth, fft_size])
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))
Мы также можем отображать частоты и названия станций на экране. Алгоритм преобразования Фурье дает на выходе 1024 точки, соответствующие полосе пропускания приемника. Мы знаем центральную частоту, поэтому вычислить положение пикселя можно по формуле для начальной школы.
stations = [("101.8 FM", 101.8), ("Rock FM", 102.4), ...]
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))
Собственно все, мы можем запускать обе программы одновременно, а на экране мы получаем панораму, показывающую в настоящее время работающие FM-станции в реальном времени:
Легко видеть, что разные станции имеют разные уровни сигнала, и мы даже можем различать моно и стереовещание.
Ну а теперь покажу обещанную панораму всего FM-диапазона. Для этого мы должны отложить RTL-SDR и использовать более качественное радио. Например, вот так:
Я использую SDR Ettus Research профессионального уровня, но с точки зрения кода все то же самое, просто нужно заменить один блок на другой в GNU Radio. И так выглядит спектр с полосой приема 24 МГц:
Интересно наблюдать за разнообразием силы сигнала от разных FM-станций.
Конечно, можно принимать не только FM-станции, но и любые другие в пределах рабочих частот SDR. Например, так выглядит воздушная повязка:
Мы можем видеть некоторые постоянно работающие частоты (вероятно, метеослужбу ATIS) и прерывистую радиосвязь между землей и пилотами. А вот так выглядит спектр GSM-диапазона (GSM-сигнал шире 24 МГц и не умещается полностью):
Вывод
Как видим, изучение радиоспектра довольно увлекательно, особенно в 3D. Конечно, здесь не было цели делать «еще один анализатор спектра», это просто прототип, сделанный для развлечения. Увы, рендеринг идет медленно, Python - не лучший выбор для отображения нескольких тысяч примитивов на экране. Алгоритм раскраски линий также может быть улучшен.
Как всегда, желаю всем читателям удачных экспериментов.
Полный исходный код рендеринга прилагается ниже.
import numpy as np from matplotlib import pyplot as plt from PIL import Image, ImageDraw import sys import pygame from pygame.locals import * from threading import Thread import io import cv2 import time import socket # 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 datasock = 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)
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*datasock = 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)
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