Random Quote Board

How to Create a Basic Spectral Display Using Gnu Radio Companion

Gary Schafer, May 2026

You've seen'em. You love'em. Spectral displays! They're colorful, they can do all kinds of great things and, if you understand what they're showing you, can tell you all kinds of great things.

This post is essentially a written version of a Youtube video I created to cover the basics of how a spectral display works. As with the video, I'll be using Gnu Radio Companion to build this spectral display. Just as I did in the video, I'll be building this display one block at a time in order to explain what each component does.

The Signal Source

For a signal source, I'll be using a File Source with a Throttle block. (NOTE: I did a quick check, and I have almost a terabyte of IQ samples in various files. Might as well make use of them.) This particular file consists of complex, 8-bit signed samples. I used the "IChar to Complex" block to transform the samples into the 32-bit floating point samples that GRC uses for most of its processing.

Gnu Radio Companion flowgraph showing four blocks in a horizontal line, each connected by lines between consecutive blocks. One block is connected on the far right just below the last block on the horizontal line. Two blocks unconnected to anything appear form a short row on the top, left.
This is the initial flowgraph. It shows the three blocks used to import the samples (File Source, IChar to Complex, Throttle), and two blocks used to display the samples (Time and Frequency Sinks).
Two graphs, one each in two rows. The top graph shows what appears to be random samples forming an almost solid block from left to right. The bottom graph is a spectral display showing multiple FM broadcast stations, both analog and digital forms.
Graphs showing the time-domain (top) and frequency domain (bottom) of the samples from the file. This is just a "sanity check" that the samples will provide a good spectrum.

Creating the Time Record

The fast Fourier transform (FFT) is a block-based algorithm. We need to pack a group of samples together into one of these blocks. For this, I'm using a variable "N" to set the block size. In GRC, I'm also going to use the "Stream to Vector" block to create the collection of samples. As I'm also going to continue to use the Time Sink for displaying the various stages, I'm adding a follow-on "Vector to Stream" as the Time Sink block can only process samples, not vectors of samples. The last part is to set the "Number of Points" in the Time Sink to the block size of "N".

For the rest of this post, assume that any new blocks added will need to have their "Vector Size" set to "N".

The result is not something that is really viewable by the end user. It will not appear any different than the initial flowgraph.

Gnu Radio Companion flowgraph showing six blocks in a horizontal line, each connected by lines between consecutive blocks. One block, just above this row near the center, is connected to the third block. Three blocks unconnected to anything appear form a short row on the top, left.
GRC flowgraph showing the addition of the "Stream to Vector" block to create the block of samples that will be used for the remaining processing. Again, this is required due to the fact that the FFT is a block-based algorithm.

Windowing the Data

This is where we talk about a not-well-understood-by-many concept with respect to the FFT. The FFT operates on data that is sampled in the time domain. It then creates a spectrum that is sampled in the frequency domain. This is where we discuss the basic concept of:

\[ \Large Sampling <==> Repetition \]

What this means is that sampling in one domain (time or frequency) imposes repetition in the other domain. For example, since the data fed into the FFT is sampled time domain data, the frequency domain will repeat. This leads to the concept of "Nyquist zones" (the repeating pattern of the spectrum between +/- 1/2 of the sample rate).

Spectral display with two vertical lines at the 1/3 and 2/3 marks horizontally. The center area is entitled 'Nyquist Zone 1'. The outer two areas are both entitled 'Nyquist Zone 2'. The spectrum in each area is identical to the spectrum in the other, two areas.
This shows the spectrum of a sampled signal. In this instance, it's showing the first two Nyquist zones. The first Nyquist zone (Nyquist zone 1) is the one that is normally shown on digital spectral displays. The other two displays (one on the negative end and the other on the positive end) are the repeating pattern created by the time domain sampling.

The exact, same concept applies for a sampled frequency domain. A sampled frequency domain imposes repetition in the time domain. To demonstrate this, I created a flowgraph that calculates the FFT using a rectangular window (effectively does not window), zeropads the FFT output, then calculates the inverse FFT (IFFT).

Gnu Radio Companion flowgraph showing three rows of blocks. The top row is three blocks that are unconnected to anything else. The bottom two rows show each block connected to the consecutive blocks in each row.
GRC flowgraph to demonstrate how the time domain repeats due to the frequency domain sampling. This flowgraph calculates the FFT for a simple complex sinusoid, zeropads the spectral points, then calculates the inverse FFT. The result is displayed in the time sink, and will show how the time domain repeats.
Time domain graph showing two overlapping sinusoids, which are 90 degrees out of phase with each other.
Repeating time domain sinusoid in which the input signal is a complex sinusoid that is precisely an integer number of cycles during the time record. Due to the integer number of cycles, it repeats such that it is not possible to tell where the time record ends or begins repeating.
Time domain graph showing two overlapping sinusoids, which are 90 degrees out of phase with each other. At 1/3 of the way from the left to the right, the sinusoids change phase, then again at 2/3 of the way from the left.
Repeating pattern for a complex sinusoid that is a non-integer number of cycles. Note how the phase changes abruptly at 1/3 and 2/3 of the way from left to right. These are where the time domain record starts repeating. If the beginning of the time record does not match the end of the time record, it creates these large impulses in the time domain. This, in turn, creates a jump in the noise floor. This is "spectral leakage".

This leads to the window, a set of weights typically applied to the time record. This window has a shape that is similar to that shown below.

A graph showing a bell-curve shape going from left to right.
This is a time domain plot showing the general shape of a window. It effectively "pinches" the ends of the time record to zero, so that if there is a discontinuity where the time domain "repeats", it will get pushed to zero.
Windowed time domain sinusoid that is repeated three times.
The windowed time domain shows that, where the time domain record repeats, the ends have been pushed to zero. This eliminates the impulses due to discontinuities.

To add the windowing to the flowgraph, I used a "Variable" block and a "Multiply Const" block, as shown below. The "Variable" block contains the window weights, and its applied to the samples using the "Multiply Const" block. (NOTE: I realize that the "FFT" block in Gnu Radio Companion has an entry for the window, but I prefer to window separately so that you can see the effect that windowing has on the data. So there.)

A block diagram showing two horizontal rows of blocks. The top row contains four blocks that are not connected to any other block. The bottom row has the blocks connected by thin lines going from left to right.
GRC flowgraph that adds the windowing to the time record. The variable "w" is for the time domain weights (the window), and in GRC parlance is "window.blackmanharris(N)". NOTE: This is a Blackman-harris window, but can also be set to other window types. This is just for demonstration purposes.
A graph showing time domain data that has been windowed. The samples have been pushed to zero at each end.
This is the time record after it has been windowed. Note how each end has been pushed to zero, eliminating the discontinuities that might exist between beginning and end of the time record.

As you can see, the window "pinches" each end of the time record to zero. (NOTE: Not all windows will go to zero. The Hamming window, for example, only goes down to 0.08.) This alleviates the spectral leakage problem, as well as a good portion of the scalloping loss (depending on the window used).

Applying the FFT

Now that we've created our block of data (the time record of samples) and windowed it, it's time to apply the actual FFT transform. Here's where I'm going to take my time as I want to explain how I view the FFT (with a shout-out to Andrei, a viewer of my Youtube channel, for his insight).

The FFT transforms the time domain samples into the frequency domain. What, exactly, is it doing? The way that I look at it is that it is correlating the time domain samples with a series of frequency-shifted samples. Let's start by looking at the equation for calculating the discrete Fourier transform.

\[ \LARGE DFT = X[ m ] = \sum_{n=0}^{N-1} x[n] e^{ {-j 2 \pi n m / N}} \]

where:

Each frequency value X[m] is a complex sample. Here's how each point is calculated (again, in my mind):

  1. At some frequency point m, we create a complex sinusoid waveform. This waveform will be an integer number (m) of cycles.
  2. The time domain samples will be multiplied on a point-by-point basis with complex sinusoid. This will frequency shift the spectrum of the time domain data for each frequency point such that that point will be shifted to 0.
  3. The frequency-shifted data will be summed, creating a correlation value for both the real and imaginary component at that frequency point.
  4. As each frequency point is represented by a real and imaginary component, this is equivalent to being represented by a magnitude and phase.

We can look at that complex sample as either a real and imaginary component, or as a magnitude and phase. These are equivalent representations.

\[ \LARGE X + jY = M \angle \theta \]

where:

To summarize, the complex sinusoid is shifting the spectrum of the data (represented by x[n]) so that each frequency point is shifted to DC (0 Hz). It then sums the data, creating an average (correlation value) at that frequency point. This is how the DFT works. The FFT is nothing more than a very efficient algorithm for calculating the DFT.

Now its time to actually add the FFT block to our flowgraph.

Block diagram showing three horizontal rows of blocks. The top row consists of four blocks that are unconnected. The bottom two rows are four or five blocks connected in horizontal fashion from left to right.
Gnu Radio Companion flowgraph adding the FFT block. The FFT block uses a length of N, and the "Window" value is set to an amplitude normalization value of "[1/N,]*N".
Graph showing a red and blue lines overlapping with vertical variations appearing as impulses.
This is how the output of the FFT appears. Each point is a complex sample representing the real and imaginary value at that frequency point. That point is calculated as the correlation of the time domain samples after they have been frequency shifted to 0.

The difference between this and the "normal" spectral display is that:

  1. Each point is represented by a complex sample, which means it represents both the magnitude and phase at that point. A normal spectral display only shows the magnitude.
  2. The vertical scale is linear. Normal spectral displays use a logarithmic scale (decibel or dB).

Calculating the Magnitude

Spectral displays show the magnitude of the signal at each frequency point. This is one of those, "Well, actually..." moments, too. They don't show the magnitude but the square of the magnitude or the magnitude squared. There are two ways we can do this. We can use the "Complex to Mag" block, or the "Complex to Mag^2" block. Either will work if you're not going to average the spectral data, but the "Complex to Mag^2" block is the better one to use. There are two reasons it is better.

  1. Since the magnitude value should be the magnitude squared, there's no reason to calculate the square root. Just calculate the value X2+Y2, and you're done. Calculating the square root is computationally expensive, anyway. So calculating the magnitude, then squaring it, is far more processing intensive and is unneeded.
  2. Calculating the average using the magnitude squared provides a more precise value than if using the magnitude value.
Block diagram showing three horizontal rows of blocks. The top row consists of four blocks that are unconnected. The bottom two rows are four or five blocks connected in horizontal fashion from left to right.
To calculate the magnitude squared for each spectral point, I've added the "Complex to Mag^2" block.
Block diagram showing three horizontal rows of blocks. The top row consists of four blocks that are unconnected. The bottom two rows are four or five blocks connected in horizontal fashion from left to right.
This is the magnitude display. Because the magnitude is calculated as the squared magnitude, the resulting values will be quite small. (The square of a value less than 1 will be a value that is is much less than 1.) These are the final magnitude values, but on a linear scale.

Transforming to Log Scale

The last step for creating a basic display is to transform the magnitudes from a linear scale to a logarithmic (decibel) scale. For this, we're going to use the "Log10" block. Adding this block and changing the "n" value to "10" (and, of course, setting the "Vector Size" to "N"), we transform the magnitude values to decibels (dB).

Block diagram showing three horizontal rows of blocks. The top row consists of four blocks that are unconnected. The bottom two rows are four or five blocks connected in horizontal fashion from left to right.
The "Log10" block transforms the linear magnitude values to logarithmic (decibel or dB) values. For this flowgraph, I've enabled the original frequency sink so that it can be compared to the spectral display in the time sink.
Two graphs showing spectral displays. Both show a similar spectral display.
This shows a comparison between the created spectral display (top) and the spectral display from a QT GUI Frequency Sink (bottom). Note that they are similar because they are the same.

Adding the Vector Sink

Note that, up til now, we've not made use of the sample rate in any of the blocks except for the last one. And it didn't really apply. That's because the FFT does not care about sample rates. All the FFT knows is that you're feeding it a certain number of samples, which it is then performing calculations on. If the data is time domain data, then the output will be the frequency domain data. But, again, it has no idea of "sample rates" or "frequency" or anything like that. Those are scales that are applied afterwards. For this flowgraph, we'll use the "QT GUI Vector Sink". It will allow us to add a proper frequency scale on the horizontal axis.

Block diagram showing three horizontal rows of blocks. The top row consists of four blocks that are unconnected. The bottom two rows are four or five blocks connected in horizontal fashion from left to right.
Flowgraph changing the output "Vector to Stream" and "Time Sink" blocks for a single QT GUI Vector Sink block. This allows us to add a proper frequency scale on the horizontal axis. The scale has been adjusted so that it is measured in MHz (megahertz). Hence, the starting point (normally 1/2 of the sample rate) is that value divided by 1e6. The original value was samp_rate/2, so dividing by 1e6 means the final calculation is samp_rate/2e6. The step size is samp_rate/N. To convert that to MHz, as well, we add a final "/1e6" to it, so it becomes "samp_rate/N/1e6".
Graph showing a spectral display with a horizontal scale calibrated in megahertz.
This is the final display, but with the horizontal scale shown in MHz. This is effectively equivalent to the QT GUI Frequency Sink.

This final result is equivalent to the QT GUI Frequency Sink.

Summary

And that, my friends, is how you create a basic spectral display. In the next post, I'll dive into making the vertical scale "normalized" so that you can, at the very least, get an idea of how well your SDR quantizer works by measuring its ENOB. I'll also go into how to properly perform averaging on the spectrum, including both an exponential and block average.

Til next time!

Here's a Random Fact...