Building an FM Receiver with C++ and gnuradio
The previous tutorial describes a typical workflow for using a Per Vices radio with gnuradio and provides an overview of how FM Broadcast Receivers work. Today we will write a Linux commandline program to use your Per Vices radio as a FM broadcast receiver in C++ without gnuradio-companion. Our program will resemble the program from the previous tutorial and will use the gnuradio C++ library.
In this tutorial, we will cover:
- How to write C++ code that interacts with your Per Vices radio
- How to use the gnuradio C++ libraries
Note
You can find a copy of the code for this tutorial here.
Requirements
Hardware Requirements
- 1 x Crimson TNG (Cyan works just as well)
- 1 x Antenna
- 1 x Pair of Speakers or Headphones
- 1 x SMA Cables
Software Requirements
Make sure you have the following programs installed. If you don’t have them installed yet, stop everything now and go install them. You can find the instructions to install them here.
- The Per Vices’ version of libUHD
- gnuradio v3.7.13.4 (we will be using the c libraries that accompany the program)
- Pulse Audio or some other way to listen to audio on your computer
- CMake
- gnu-make
Note
Most of the common package managers (like apt-get, brew, pacman) have a different version of UHD. Make sure you compile the Per Vices supported version from scratch.
Setup
Let’s start-off with a small program and make sure that we can get it to compile with all the necessary libraries.
Creating our First C++ Program
We will need a folder to store our code. As convention dictates, we should create a folder called source
. Inside the source directory, we create a fm_radio.cpp
where we will code everything up. Here is a small program that has some UHD libraries. We can use it to make sure that our code compiles properly:
#include <iostream>
#include <uhd/utils/safe_main.hpp>
#include <uhd/utils/thread.hpp>
int UHD_SAFE_MAIN(int argc, char *argv[])
{
uhd::set_thread_priority_safe();
std::cout << std::endl
<< "done!"
<< std::endl;
return EXIT_SUCCESS;
}
We have our starting point. Next let’s make sure this code can compile!
Setting up CMake and make
CMake is the canonical meta-build system for C++ development. We will use it to include all our external libraries and generate the Makefile that will compile our code. Once we have a Makefile, we can use the make
command to build our dependencies.
To setup our program:
-
First we will need a
CMakeLists.txt
file that will guidecmake
in generating a makefile for all our dependencies. You can obtain a copy of the one used for this project here. The file can be obtained in one of two ways:a) Using the command line, go to the root of the project and type
wget https://raw.githubusercontent.com/pervices/examples/develop/grc-fm-receiver-cpp/CMakeLists.txt
, orb) Download the file and move it to the root of the project directory
-
You may want to skim through the
CMakeLists.txt
file. Most of the contents is rather dull declarations that ensure that our C++ compilers use the correct set of libraries. - Create a
build
directory. This is where our commandline program will exist once we make it. - Run
CMake ..
within thebuild
directory. You should now have a series of new files, the most important of which is theMakefile
. - You can now build the project by running
make
within thebuild/
directory - You can now largely ignore the
CMakeLists.txt
file. Our libraries are linked, we have aMakefile
, andcmake
has served it’s purpose. Each time you make changes to thesource/fm_radio.cpp
file, you will need to runmake clean
andmake
from within the build directory.
Note
If you want to choose a different filename for the source/fm_radio.cpp
program or the build/fm_radio
executable, make the necessary changes in the CMakeLists.txt
and redo steps 2 through 6.
Your project should now have a directory structure that resembles the following structure:
├── source
| └── fm_radio.cpp
├── build
| ├── fm_radio
| ├── CMakeCache.txt
| ├── CMakeFiles
| ├── Makefile
| ├── cmake_install.cmake
| └── compile_commands.json
└── CMakeLists.txt
Running our program
The fm_radio
file is the command line radio utility that we will incrementally be building. You should be able to run it from the command line by either typing ./build/fm_radio
from the root of your project or ./fm_radio
from the build directory.
Usage
We have our libraries in the right place and our code compiling! Our program is able to print some output, so let’s make that output helpful. More specifically, let’s have a function that will remind us how to use our executable.
void print_usage(po::options_description desc)
{
std::cout << "Usage: fm_radio [OPTIONS] ..." << std::endl
<< "Receives frequency modulated signals from the specified station." << std::endl
<< std::endl
<< desc << std::endl
<< std::endl
<< "Examples:" << std::endl
<< " fm_radio --help" << std::endl
<< std::endl;
}
We will want to make sure we can call this function within our main program. To do that we will need to add some more code to the main function:
po::options_description desc;
if (argc <= 1) {
print_usage(desc);
return ~0;
}
This is how our file should look so far:
#include <boost/program_options.hpp>
#include <iostream>
#include <uhd/utils/safe_main.hpp>
#include <uhd/utils/thread.hpp>
namespace po = boost::program_options;
void print_usage(po::options_description desc)
{
std::cout << "Usage: fm_radio [OPTIONS] ..." << std::endl
<< "Receives frequency modulated signals from the specified station." << std::endl
<< std::endl
<< desc << std::endl
<< std::endl
<< "Examples:" << std::endl
<< " fm_radio --help" << std::endl
<< std::endl;
}
int UHD_SAFE_MAIN(int argc, char *argv[])
{
uhd::set_thread_priority_safe();
po::options_description desc;
if (argc <= 1) {
print_usage(desc);
return ~0;
}
std::cout << std::endl << "done!" << std::endl;
return EXIT_SUCCESS;
}
Program Arguments
Programs that can accept commandline arguments are more versatile, but I often forget which flags to pass. With the help of the program option tool from the boost library, let’s add program arguments and document them properly.
The #include <boost/program_options.hpp>
header will assist us greatly. Typing out boost::program_options
every time we wish to interact with the library will become unwieldy. For convenience, we can assign it to a namespace.
namespace po = boost::program_options;
Once we collect the inputs, we will need a structure to store them. Consider using either a struct or a class. If you do choose to use a struct, make sure that you allocate and deallocate memory for it properly.
To declare a class:
class UserArgs
{
public:
std::string program_name;
...
double sample_rate;
...
size_t channel;
};
Then to set the program args, we can specify them as follows:
po::options_description set_program_args(std::shared_ptr<UserArgs> user_args)
{
po::options_description desc("Allowed options");
desc.add_options()
("help", "help message")
("station", po::value<double>(&(user_args->station))->default_value(99.9),
"the fm station you wish to tune into")
...
("setup", po::value<double>(&(user_args->setup_time))->default_value(1.0),
"seconds of setup time");
return desc;
}
Here is a list of all the arguments we will want to accept:
Argument | Type | Default Value | Purpose |
---|---|---|---|
program_name | string | fm_broadcast_receiver | a descriptive name for the program |
device_addr | string | ”“ | the fm station you wish to tune into |
ref_src | string | internal | should be an integer between 0 and 10 |
sample_rate | double | 1e6 | rate of incoming samples |
output_rate | double | 43e3 | the rate at which samples will be fed to the computer’s audio card |
volume | double | 5.0 | should be an integer between 0 and 10 |
setup_time | double | 1.0 | seconds of setup time |
deviation | double | 75e3 | fm broadcast deviation |
channel | size_t | 0 | which channel to use |
Just like last time, we will need to add some code to the main function to make sure that the set_program_args
function is called with the correct arguments:
po::variables_map vm;
std::shared_ptr<UserArgs> user_args(new UserArgs());
po::options_description desc = set_program_args(user_args);
po::store(po::parse_command_line(argc, argv, desc), vm);
po::notify(vm);
if (vm.count("help") or argc <= 1) {
print_usage(desc);
return ~0;
}
Writing our program
Enough with printing text to the screen, let’s create an FM Broadcast Receiver like the tutorial promised!
Overview
The diagram above shows two flow graphs created using gnuradio. The diagram on top is a simplified version of the flow graph from the previous tutorial, while the diagram on the bottom is the flow graph for this tutorial. The only difference between them is that instead of using the FM Demod block, we will be replacing it with two other blocks that combine to fullfill the same purpose: the Quadrature Demod block and Decimating FIR Filter block. Other than that, we will be recreating the flow graph in much the same way.
Note
As we start building our flow graph, it’s a good idea to make sure that all the variables in the graph above are accounted for.
Preparing our device
Now using the program arguments, let’s get our radio into a more usable state. As always, a new task means we will use a separate function.
int setup_usrp_device(uhd::usrp::multi_usrp::sptr usrp_device, std::shared_ptr<UserArgs> user_args,
double *actual_sample_rate, double *actual_gain, po::variables_map vm)
{
// Instantiate Device
if (not((user_args->ref_src.compare("internal")) == 0 or
(user_args->ref_src.compare("external")) == 0 or
(user_args->ref_src.compare("mimo")) == 0)) {
std::cerr << "Invalid reference source. Reference source should be one of (internal, "
"external, mimo)"
<< std::endl;
return ~0;
}
std::cout << boost::format("Instantiating the usrp usrp_device device with address: %s...") %
user_args->device_addr
<< std::endl;
usrp_device->set_clock_source(user_args->ref_src); // lock mboard clocks
// Set the Sample Rate
double desired_sample_rate = user_args->sample_rate;
if (user_args->sample_rate <= 0.0) {
std::cerr << boost::format("%s is not a valid sample rate.") % desired_sample_rate
<< "The sample rate needs to be a positive float"
<< "Please specify a valid sample rate." << std::endl;
return ~0;
}
std::cout << boost::format("Setting RX Rate: %5.2f Msps...") % (desired_sample_rate / 1e6)
<< std::endl;
usrp_device->set_rx_rate(desired_sample_rate, user_args->channel);
*actual_sample_rate = usrp_device->get_rx_rate(user_args->channel);
std::cout << boost::format("Actual RX Rate: %f Msps...") % (*actual_sample_rate / 1e6)
<< std::endl;
// Set the RF Gain
double desired_gain = user_args->volume * 1e-1;
std::cout << boost::format("Setting RX Gain: %f dB...") % desired_gain << std::endl;
usrp_device->set_rx_gain(desired_gain, user_args->channel);
*actual_gain = usrp_device->get_rx_gain(user_args->channel);
std::cout << boost::format("Actual RX Gain: %f dB...") % *actual_gain << std::endl;
// Sleep a bit while the slave locks its time to the master
std::this_thread::sleep_for(std::chrono::seconds(int64_t(user_args->setup_time)));
return 0;
}
Like before, the function is useless unless it’s called.
double center_frequency = user_args->station * 1e6;
double sample_rate, gain;
uhd::usrp::multi_usrp::sptr usrp_device = uhd::usrp::multi_usrp::make(user_args->device_addr);
if (setup_usrp_device(usrp_device, user_args, &sample_rate, &gain, vm) != 0) {
return ~0;
};
// create a receive streamer
std::vector<size_t> channel_nums;
channel_nums.push_back(0);
uhd::stream_args_t stream_args(STREAM_ARGS);
stream_args.channels = channel_nums;
uhd::rx_streamer::sptr rx_stream = usrp_device->get_rx_stream(stream_args);
gnuradio imports
Hooking up the flow graph
Now our radio is ready to program. We can start to create our flow graph.
Top Block
The first block in any gnuradio program is always the top block. In this case creating it is just a one-liner:
gr::top_block_sptr tb = gr::make_top_block(user_args->program_name);
Source Block
First we will need to create what will be the equivalent of the USRP Source block.
gr::uhd::usrp_source::sptr usrp_source;
usrp_source = gr::uhd::usrp_source::make(user_args->device_addr, stream_args);
usrp_source->set_samp_rate(sample_rate);
usrp_source->set_center_freq(center_frequency);
Resampler
To obtain more samples, we will need to decimate our samples using a Rational Resampler block.
std::vector<float> resampler_taps = design_filter(INTERPOL_FACTOR, DECIMATE_FACTOR_RR);
gr::filter::rational_resampler_base_ccf::sptr resampler =
gr::filter::rational_resampler_base_ccf::make(INTERPOL_FACTOR, DECIMATE_FACTOR_RR,
resampler_taps);
The Rational Resampler block in gnuradio-companion comes with a default filter that is implemented using taps. Unfortunately, the C++ library does not have this luxury. Thus we will need to write our own filtering function to provide the taps:
std::vector<float> design_filter(int interpolation, int decimation, float fractional_bw = 0.4)
{
if (fractional_bw >= 0.5 or fractional_bw <= 0) {
std::cerr << "Invalid fractional_bandwidth, must be in (0, 0.5)";
}
const double halfband = 0.5;
const double beta = 7.0;
double rate = double(interpolation) / double(decimation);
double trans_width, mid_transition_band;
if (rate >= 1.0) {
trans_width = halfband - fractional_bw;
mid_transition_band = halfband - trans_width / 2.0;
} else {
trans_width = rate * (halfband - fractional_bw);
mid_transition_band = rate * halfband - trans_width / 2.0;
}
std::vector<float> taps;
taps = gr::filter::firdes::low_pass(interpolation, interpolation, mid_transition_band,
trans_width, gr::filter::firdes::WIN_KAISER, beta);
return taps;
}
Quadrature Demodulator
After we resample, our new sample rate is what we shall refer to as the channel_rate
. We can use this to figure out the quadrature demodulator’s coefficient.
float channel_rate = sample_rate / DECIMATE_FACTOR_DEMOD;
float k = channel_rate / (2 * M_PI * user_args->deviation);
gr::analog::quadrature_demod_cf::sptr quad_demod = gr::analog::quadrature_demod_cf::make(k);
Fir Filter
At this point in time, our channel rate is still sample_rate / DECIMATE_FACTOR_DEMOD = 1MHz / 4 ~ 250 kHz
. However, the sound card on most computers can only sample at ~ 48 kHz
. This means that we will need to filter our output once more after the filtering. To to this, we can use a standard low pass filter.
std::vector<float> audio_taps;
gr::filter::fir_filter_fff::sptr fir_filter;
double transition_width = 1e3;
audio_taps = gr::filter::firdes::low_pass(gain, user_args->output_rate, 15e3, transition_width);
fir_filter = gr::filter::fir_filter_fff::make(DECIMATE_FACTOR_DEMOD, audio_taps);
Audio Sink
We will need this to be able to hear the audio output.
gr::audio::sink::sptr audio_sink = gr::audio::sink::make(user_args->output_rate);
Assembling flow graph
Now that we have all the blocks, let’s hook them together.
tb->connect(usrp_source, 0, resampler, 0);
tb->connect(resampler, 0, quad_demod, 0);
tb->connect(quad_demod, 0, fir_filter, 0);
tb->connect(fir_filter, 0, audio_sink, LEFT_CHANNEL);
tb->connect(fir_filter, 0, audio_sink, RIGHT_CHANNEL);
Polling the exit signal
Lastly, we will want to start the flow graph. The signal handler will ensure that the flow graph, i.e. our program, will keep running until you press Ctrl + C on the keyboard.
std::cout << "Starting flow graph" << std::endl;
tb->start();
if (DEBUG) {
tb->dump();
}
std::signal(SIGINT, &sig_int_handler);
std::cout << "Press Ctrl + C to exit." << std::endl;
while (not stop_signal_called) {
boost::this_thread::sleep(boost::posix_time::milliseconds(1000));
}
tb->stop();
std::cout << std::endl << "done!" << std::endl;
The Final Product
Now when you run the binary using ./fm_radio
from within the build
folder, you should be able to hear the FM Broadcast.