~derf / interblag / entry / Software-Defined FM Audio Transmission with ADI / PlutoSDR

ADI / PlutoSDR / PySDR are powerful tools for transmitting IQ samples. Here's how to transmit FM data with them, which can then be received, e.g., via a ham radio handset. I'll also try to explain how the whole transmission business works – however, my understanding of high frequency data transmission witchery is not that good (yet), so you'd better double-check anything you find here.

Note that emitting FM transmissions may be considered illegal depending on your jurisdiction, TX power, EIRP, and/or frequency band. This example is going to use the ham radio band, which requires you to have a license and mention your callsign in transmissions in all countries that I am aware of.

No lawyers or regulators were harmed in the making of this blog post (I do have a valid ham radio license and call sign. DF7LUX meowing here!).

Software Setup

See the pysdr.org documentation for a detailed how-to. In my case (Debian unstable), installing the required dependencies (libad9361-dev, libaio-dev), activating a Python3 virtualenv, and running the following commands within it was sufficient.

git clone --branch v0.0.14 https://github.com/analogdevicesinc/pyadi-iio.git
cd pyadi-iio
pip3 install --upgrade pip
pip3 install -r requirements.txt
pip install setuptools scipy
python3 setup.py install

Basics

PySDR / PlutoSDR works with IQ samples and applies them to a carrier frequency by itself. Its tx function takes a bunch of samples and blocks until they have been transmitted.

First, we need to define some basic parameters. Here, we're going to use 100,000 samples per tx call and a 1 MHz PySDR sample rate. Our FM transmission will use a maximum deviation of 12.5 kHz, which seems to work best for my Radioddity GD77. We're also going to use the ham radio 144.6 MHz band for this test.

n_samples_per_tx = 100_000
sdr_sample_rate = 1_000_000
fm_deviation = 12_500
fm_carrier = 144_600_000

Now, in order to get started, we need to import some modules:

import adi
import numpy as np
import time
import scipy.io
import scipy.signal

Reading in a WAV file

Given a 16-bit signed WAV file, we can use scipy.io.wavfile to load it.

wav_sample_rate, data = scpiy.io.wavfile.read("some audio that includes your callsign.wav")

We're going to do a simple mono transmission, so in case the file holds stereo data, we'll throw away the second channel:

len(data.shape) == 2:
    data = data[:, 0]

The WAV file holds 16-bit signed data (-32767 to 32768), whereas PySDR expects its samples to be 15-bit signed (-16383 to 16384) and the operations we're going to perform later assume floating point data in the range -1 to 1. Hence, we scale all samples to be within [-1, 1].

data = data / 2**15

Resampling

WAV files typically have a sample rate of 44.1 or 48 kHz; our SDR expects 1 MHz. So, for every sample in the WAV file, we must pass (roughly) 20 samples to the SDR.

We can use scipy.signal.resample to perform this transformation: we have data.shape[0] input samples and must stretch the number of samples by the ratio of SDR sample rate to WAV sample rate.

samples = scipy.signal.resample(
    data, int(data.shape[0] * (sdr_sample_rate / wav_sample_rate))
)

Note: this function seems to be quite memory-hungry. Alternatively, you can try the following snippet, which is not how it should be done but seems to be work well enough in practice.

# samples = np.repeat(data, sdr_sample_rate // wav_sample_rate)

Low-Pass Filter

The Nyquist-Shannon theorem states that a receiver operating with a specific sampling rate f can only reconstruct signals with frequencies of up to f/2 correctly. Any higher transmitted frequencies may cause aliasing, which may or may not cause unwanted effects for audio data.

As far as I understand it, this does not quite apply to FM transmissions – we've got 12.5 kHz FM deviation, but the SDR's sample rate, and thus the available bandwidth, is 1 MHz – or something between those two frequencies, anyway. In any case, applying a digital filter that throws away anything abore 12.5 kHz (and below 20 Hz for good measure) won't hurt. I'm not familiar with the intricacies of scipy.signal yet – this Finite Impulse Response filter works, but is copy-pasted and adjusted based on best guesses. So you might want to do your own research here.

bb = scipy.signal.firwin(41, (20, fm_deviation), pass_zero=False, fs=sdr_sample_rate)
samples = scipy.signal.lfilter(bb, [1], samples)

scipy.signal offers lots of different filtering methods; there may be better / more elegant ways of low-pass filtering.

Interlude: AM Transmission

Our audio signal is now almost ready for transmission. In fact, if all we wanted to do is AM (amplitude modulation), we would already be done. Our SDR uses IQ sampling, which means that each sample consists of two component: I (multiplied with the cosine of the carrier frequency) and Q (multiplied with the sine of the carrier frequency). The actual transmitted signal is x(t) = I · cos(2πft) + Q · sin(2πft), with I and Q belonging to individual samples in the TX buffer and f being the carrier frequency. With a sample rate of 1 MHz and a carrier of 144.6 MHz, each sample is used for 144.6 periods of the underlying carrier's sine / cosine waves.

In PySDR, IQ samples are represented as complex numbers I + jQ, where I is the real part and Q the imaginary one. As samples contains purely real values, we obtain x(t) = I · cos(2πft), meaning that our samples I are modulating the amplitude of the carrier frequency -- or, in short, we're doing AM.

Generating IQ Samples for FM Transmission

In order to do FM, we must adjust the frequency of the carrier rather than its amplitude – the amplitude should always remain at its maximum value. So, if samples[i] == -1, we want to shift the carrier down by -12.5 kHz to transmit a 144.5875 MHz signal. If samples[i] == 0, we want to leave it as-is (144.600 MHz), and for samples[i] == 1, we want to shift it by +12.5 kHz to end up at 144.6125 MHz.

With IQ sampling, we can control only two aspects of the 144.6 MHz carrier transmitted by the SDR: its amplitude (see above) and its phase. We cannot adjust the frequency directly. However, we can control the phase for each sample separately.

If we transmit two consecutive samples with a different phase, this will affect the carrier's sine wave right at the transition between the two samples. For a positive phase difference, the sine will be slightly “too slow”, i.e., essentially have a lower frequency than the configured carrier. For a negative phase difference, the sine will be slightly “too fast”, i.e., essentially have a higher frequency than the configured carrier. This is called phase modulation, and the nice thing about it is that it can also be used for frequency modulation.

Interlude: Frequency Modulation via Phase Modulaton

Let's assume that we had an SDR sample rate that is identical to the carrier frequency, so, 144.6 MHz. Each sample is precisely as long as a single period of the carrier signal.

If we shift the phase by 180° after each period of the carrier signal, this effectively increases the frequency of the transmitted sine wave by 50% – so, rather than a 144.6 MHz signal, we'll be transmitting a 216.9 MHz one. We'll also be transmitting a (smaller?) frequency component that is 50% slower than our carrier, so, at 72.3 MHz.

If we shift the phase by 90°, we increase (or decrease, depending on direction) the frequency of the transmitted sine wave by 25%, yielding 108.45 or 180.75 MHz. In general, if we shift the phase by φ°, we'll transmit 144.6 MHz + (φ/360°) · 144.6 MHz.

Now, with the SDR, we cannot shift the phase after each period of the carrier – we can only do so every few dozen to hundreds of carrier periods. In this specific case, with 144.6 MHz carrier and 1 MHz sample rate, we can shift the phase every 144-odd carrier periods.

Even in this case, frequency modulation via phase moulation still works. Let's say that we shift the phase every ten carrier periods for now. Now, we're only doing this “± 50%” dance on 10% of all periods, and, as luck would have it, that also means that our frequency deviation is just 10% as high: we get 5% shift of the carrier frequency rather than the 50% we had before. So, a 180° shift will cause the frequency spectrum to split into two peaks: one is 5% higher and one 5% lower, i.e., one at 137.37 MHz and one at 151.83 MHz. A 90° shift will cause the frequency to increase / decrease by 2.5%, and so on.

Generally speaking: A phase shift of φ° every n carrier periods will cause the transmitted frequency to change by (φ/360°) · (1/n) · 144.6 MHz.

And even more generally speaking, since n = carrierFrequency / sampleRate: A phase shift of φ° will cause the transmitted frequency to change by (φ/360°) · (1/(carrierFrequency/sampleRate)) · carrier, which simplifies to (φ/360°) · sampleRate.

FM Transmission

Now all that's left is determining the correct amount of phase change for each audio sample. At 1 MHz sample rate, the maximum we could do (with 180°, independent of carrier frequency) is ± 500 kHz, and we only need ± 12.5 kHz – so, the maximum required phase change is ± 4.5°.

We can also calculate that directly:

  • deviation = (φ/360°) · sampleRate
  • ⇒ φ = 360° · deviation / sampleRate
  • ⇒ φ = 360° · 12.5 kHz / 1 MHz
  • ⇒ φ = 4.5°

If we convert to radians (i.e., normalize 180° to π, which is how PySDR likes it), that's x = 2π · deviation / sampleRate.

At this point, there is one thing which I still do not understand: my code only works with x = π · deviation / sampleRate (note the missing factor of two). I suppose that I have simply mis-assumed the frequency deviation and should have used 6 kHz instead, but for now, I'm gonna leave this as-is. Let me know if you know more ^.^

So, what we finally have is:

phase_changes = samples * np.pi * fm_deviation / sdr_sample_rate

PySDR takes in absolute phase information, so we need to calculate the cumulative sum of this phase change array:

phase_integral = np.cumsum(phase_changes)

And with that, we can tell PySDR that we'd like to transmit a PM signal with a constant amplitude (of 1) and phases as determined by phase_integral:

fm_samples = np.exp(1j * phase_integral)

Boiler Plate

Now all that's left is scaling everything up from -1 … 1 to the 15-bit signed values that PlutoSDR expects, and then transmitting those.

fm_samples *= 2**14

sdr = adi.Pluto("ip:…")

sdr.sample_rate = int(sdr_sample_rate)
sdr.tx_rf_bandwidth = int(sdr_sample_rate)
sdr.tx_lo = int(fm_carrier)
sdr.tx_hardwaregain_chan0 = -10

print("SDR is being configured, waiting 2 seconds before beginning transmission …")
time.sleep(2)

for i in range(fm_samples.shape[0] // n_samples_per_tx):
    sdr.tx(fm_samples[i * n_samples_per_tx : (i + 1) * n_samples_per_tx])

The full script (with some quality-of-life improvements) is available at plutosdr-playground/tx-fm.py. Next item on the todo list is probably using multiprocessing so that the transmission can already start as the input file is being processed.