RF signal processing in Rust

Hi everyone!

I wonder if you have any recommendation for crates or frameworks regarding signal processing in Rust (audio and radio frequency), in particular for complex I/Q streams? This would include tasks like:

And possibly things that build on top of it, like:

And/or coding:

I'm willing to do many things on my own, but at least a good FFT library (and maybe a resampling library) might be helpful as a starting point.

Try rustfft.

1 Like

For interfacing RF hardware, I will likely use the soapysdr crate.

I have made mixed experiences with SoapySDR in the past (weird behavior when initializing certain hardware, unclear use of certain API calls, etc.), but I don't think there's many alternatives if you want to be somewhat hardware independent.


Speaking of hardware, what's a good way to access audio in/out hardware (microphone, line-out) from Rust? My focus is Linux/BSD, but a platform independent way would be nice too.

I think you're looking for this:

I've seen this used in my dependencies before, and it makes all the right noises (pun intended) in the readme, but it was just a simple search for audio, so feel free to look further!

2 Likes

I haven't tried it myself yet so I can't attest to its completeness, but FutureSDR is aiming to be a general framework like GNU Radio but in Rust.

2 Likes

Thank you, I will take a look. Writing a short stub, it seems to compile on FreeBSD too. :smiley:

That looks promising, and might be a project to contribute to if/when I implement some blocks myself. I also appreciate that it's licensed with Apache license rather than following GNU's model.

H₂CO₃ mentioned rustfft, and its author has posted here about it before, which you might find interesting:

I didn't look deeper into FutureSDR yet, but I was able to successfully receive radio transmissions (commercial FM broadcast and amateur radio) using these components:

I used tokio to make the overall processing asynchronous. I had to use tokio::task::spawn_blocking to use the blocking interface of soapysdr with tokio's async runtime. Looks something like that:

let dev = soapysdr::Device::new("").unwrap();
dev.set_frequency(Rx, 0, 433.5e6, "").unwrap();
dev.set_sample_rate(Rx, 0, 1024000.0).unwrap();
dev.set_bandwidth(Rx, 0, 1024000.0).unwrap();

let mut rx = dev.rx_stream::<Sample>(&[0]).unwrap();
let mtu = rx.mtu().unwrap();
rx.activate(None).unwrap();

let (rx_rf_send, rx_rf_recv) = channel::<Sample>(queue);
let join_handle = spawn_blocking(move || {
    let mut buf_pool = ChunkBufPool::<Sample>::new();
    loop {
        let mut buffer = buf_pool.get();
        buffer.resize_with(mtu, Default::default);
        let count = rx.read(&[&mut buffer], 1000000).unwrap();
        buffer.truncate(count);
        rx_rf_send.send(buffer.finalize());
    }
});

I then connect several futures (which get spawned) with some asynchronous channels:

let (rx_base_send, rx_base_recv) = channel::<Sample>(queue);
spawn(blocks::freq_shift(rx_rf_recv, rx_base_send, -75, 2 * 1024));

let (rx_down_send, rx_down_recv) = channel::<Sample>(queue);
spawn(blocks::downsample(
    rx_base_recv,
    rx_down_send,
    1024000.0,
    384000.0,
    12500.0,
    blocks::DownsampleOpts {
        chunk_size: 4096,
        ..Default::default()
    },
));

/* … */

cpal requires me to provide a callback which writes the audio data into a buffer. Since that callback is invoked by a thread that I don't control, I used tokio::runtime::Handle::block_on to be able to await new data:

let rt = tokio::runtime::Handle::current();
/* … */
let host = cpal::default_host();
let device = host
    .default_output_device()
    .expect("no output device available");
let supported_ranges = device
    .supported_output_configs()
    .expect("no supported audio config");
let range = supported_ranges
    .filter(|range| {
        range.channels() == 1
            && range.min_sample_rate().0 <= 48000
            && range.max_sample_rate().0 >= 48000
            && range.sample_format() == cpal::SampleFormat::F32
    })
    .next()
    .expect("no suitable audio config found");
let supported_config = range.with_sample_rate(cpal::SampleRate(48000));
let mut buffer_size = 2 * 4096;
match supported_config.buffer_size() {
    &cpal::SupportedBufferSize::Range { min, max } => {
        buffer_size = buffer_size.min(max).max(min)
    }
    &cpal::SupportedBufferSize::Unknown => (),
}
let config = cpal::StreamConfig {
    channels: 1,
    sample_rate: cpal::SampleRate(48000),
    buffer_size: cpal::BufferSize::Fixed(buffer_size),
};
let err_fn = |err| eprintln!("an error occurred on the output audio stream: {}", err);
/* … */
let write_audio = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
    for sample in data.iter_mut() {
        // method `recv_realtime` is async, i.e. it returns a future
        /* … */ rt.block_on(rx_audio_down_recv.recv_realtime(0)) /* … */
        *sample = /* … */;
        /* … */
    }
};
let stream = device
    .build_output_stream(&config, write_audio, err_fn)
    .unwrap();
stream.play().unwrap();

It's still work in progress and a bit ugly, but I'm happy it works. And I'm especially happy that the audio delay is low (which is relevant for realtime radio applications). To keep the audio delay low, I manually set a small buffer (might be good to test automatically how low it can be without causing underflows), and I monitor the len of the last tokio::sync::broadcast::Receiver to be small and discard chunks if there is congestion (which might happen because the time basis of the receiver and the audio device are not exactly synchronous).

So cpal and rustfft do fine!

Only thing that I'm missing in rustfft is some specialized transforms, e.g. for real-valued signals or chunks where half of the data is zero, etc. I currently work almost entirely in the complex domain, which makes the code a bit easier to overlook but might come with a bit of unnecessary overhead when the imaginary part is known to be zero.

P.S.: I tested this workflow on FreeBSD and with this SDR stick, but also want to try out the LimeSDR Mini to be able to transmit (the RTL SDR can only receive). Haven't tested this on Windows or Mac yet.

microfft might work for optimizing on real-valued signals.

2 Likes

I wrote a small benchmark to compare rustfft (0.6.1) and microfft (0.5.0).

[dependencies]
rustfft = "6.0.1"
microfft = "0.5.0"
rand = "0.8.5"
num-complex = "0.4.2"
use num_complex::Complex32;
use rand::{
    distributions::{self, Distribution as _},
    thread_rng, Rng,
};

use std::f32::consts::TAU;
use std::time::Instant;

fn random_complex<R>(rng: &mut R) -> Complex32
where
    R: ?Sized + Rng,
{
    let u1: f32 = distributions::OpenClosed01.sample(rng);
    let u2: f32 = distributions::Standard.sample(rng);
    let abs = (-2.0 * u1.ln()).sqrt();
    let (b, a) = (u2 * TAU).sin_cos();
    Complex32 {
        re: abs * a,
        im: abs * b,
    }
}

fn new_test_vector(len: usize) -> Vec<Complex32> {
    let mut rng = thread_rng();
    (0..len).map(|_| random_complex(&mut rng)).collect()
}

fn new_test_vectors(len: usize, count: usize) -> Vec<Vec<Complex32>> {
    (0..count).map(|_| new_test_vector(len)).collect()
}

fn benchmark<F>(name: &str, func: F)
where
    F: FnOnce(),
{
    let start = Instant::now();
    func();
    let duration = Instant::now().duration_since(start);
    println!("{}: {} ms", name, duration.as_millis());
}

fn main() {
    let nvecs = 10000;
    let rounds = 10;
    {
        let mut vecs = new_test_vectors(4096, nvecs);
        let fft =
            rustfft::FftPlanner::<f32>::new().plan_fft_forward(4096);
        benchmark("rustfft(4096)", || {
            for _ in 0..rounds {
                for vec in vecs.iter_mut() {
                    fft.process(vec);
                }
            }
        });
    }
    {
        let mut vecs = new_test_vectors(4096, nvecs);
        benchmark("microfft(4096)", || {
            for _ in 0..rounds {
                for vec in vecs.iter_mut() {
                    let _ = microfft::complex::cfft_4096(
                        (&mut **vec).try_into().unwrap(),
                    );
                }
            }
        });
    }
}

On my machine, I get (with cargo run --release):

rustfft(4096): 693 ms
microfft(4096): 3219 ms

And with a blocksize of 256:

rustfft(256): 28 ms
microfft(256): 144 ms

So microfft is about 4-5 times slower. (Not sure how well my benchmark is done.)

microfft may be nice for embedded systems, but I think for my application case, I will stick to rustfft and just fill the imaginary parts with zeroes. It might still be faster than other options. Most processing will be complex-valued anyway. The real samples are usually with a lower samplerate, so it's not that big of an issue.

1 Like

You should in general use something like criterion - Rust to verify you're seeing a real effect, but that's a big enough gap there's little doubt!

1 Like