HB9HOX

About amateur radio linux, open source and open hardware.

Oscillators for SDR applications in Rust

Posted on .

An essential part of a radio is an oscillator. This is the same for a radio implemented in hardware as well as software defined radios (SDR). This blog post explores different ways an oscillator can be implemented in software using the programming language Rust.

For the context of this post, an oscillator is a sinusoidal signal defined by the formula \(A cos(2\pi ft)\) where \(A\) is the signals’ amplitude, \(f\) is its frequency and \(t\) is the time index.

\(cos()\) or \(sin()\)?

Whether we use \(cos()\) or \(sin()\) does not make any difference. They are basically the same signal, only shifted in phase by \(90º\).

That statement holds true as long as we deal with a single real valued signal. As soon as we have other signals to compare, that phase difference can make a difference. This is for instance the case with the complex oscillator shown later in this post.

The following will explore several ways of generating a discrete sinusoidal signal. All examples will generate 100 samples (\(N\)) with a sample rate, the number of samples per second, of \(FS = 50.0\). This gives us samples over a time period of two seconds. The signals will have a frequency of \(f=3.0\) Hz and an amplitude of \(A=7.0\).

This post shows only code excerpts. An archive of the full working code base is available for download, should you want to look at an experiment with them.

Our first example is a straight forward translation of the formula into code. It works well for when we need a small, fixed count of samples.

 1use std::f32::consts::PI;
 2
 3const N: usize = 100;
 4const FS: f32 = 50.0;
 5const F: f32 = 3.0;
 6const A: f32 = 7.0;
 7
 8let s: Vec<f32> = (0..N)
 9    .map(|t| A * (2.0 * PI * F * t as f32 / FS).cos())
10    .collect();

Plot of a cosine function with an frequqncy of 3 Hz and an amplitude of 7.-8-6-4-20246800.20.40.60.811.21.41.61.82cos(2πft)

But sometimes, we do not have or do not care about the time index. In those cases, we can use an incremental approach. Here we track the value passed to our trigonometric function. In each iteration, we increment that value. This results in an ever-increasing value, effectively risking a value overflow of the variable. In Rust, an overflow will cause an application to crash. To prevent the value from overflowing, the modulo operation ensures the value is kept in check. This is possible due to the cyclic nature of the trigonometric functions.

1let mut theta: f32 = 0.0;
2let s: Vec<f32> = std::iter::from_fn(move || {
3    let ss = theta.cos();
4    theta += 2.0 * PI * F / FS;
5    theta %= 2.0 * PI;
6    Some(A * ss)
7})
8.take(N)
9.collect();

Some SDR applications, require the use of complex oscillators. Such an oscillator is effectively a complex number where the real part is a cosine and the imaginary part is a sine.

1let s: Vec<Complex<f32>> = (0..N)
2    .map(|t| {
3        let theta = 2.0 * PI * F * t as f32 / FS;
4        Complex::new(A * theta.cos(), A * theta.sin())
5    })
6    .collect();

Plot of a sine and cosine function each with a frequency of 3 Hz and an amplitude of 7.-8-6-4-20246800.20.40.60.811.21.41.61.82cos(2πft)sin(2πft)

As with the real valued example, we can omit the use of the index variable \(t\).

1let mut osc = Complex::new(A, 0.0);
2let s: Vec<Complex<f32>> = std::iter::from_fn(move || {
3    let ss = osc;
4    osc *= Complex::cis(2.0 * PI * F / FS);
5    Some(ss)
6})
7.take(N)
8.collect();

This approach also allows us to easily control the phase by using the polar notation of a complex number for the initial value.

1let mut osc = Complex::from_polar(A, PI / 8.0);

Plot of a sine and cosine function each with a frequency of 3 Hz and an amplitude of 7 shifted in phase by 45º.-8-6-4-20246800.20.40.60.811.21.41.61.82cos(2πft)sin(2πft)

As it is the case with floating point arithmetic, small inaccuracies have to be expected. When using the complex incremental approach, the minimum and maximum value will not necessarily be exactly equal to the amplitude.

1let max = s.iter().fold(f32::MIN, |m, x| m.max(x.re.max(x.im)));
2let min = s.iter().fold(f32::MAX, |m, x| m.min(x.re.min(x.im)));
3println!("min: {}, max: {}", min, max);
min: -7.0000005, max: 7.000001

This can become relevant, when we need to convert our values to other data types. For example, when sending samples to an SDR hardware frontend that takes integer values as its input. If not addressed, clipping can cause distortion of our signal.

In SDR applications, some common operations require complex numbers as their input. But what if we only have real values? We can convert a real number into a complex one, while preserve the properties of the real signal, by creating a complex number with the complex part set to zero.

1let s: Vec<Complex<f32>> = (0..N)
2    .map(|t| A * (2.0 * PI * F * t as f32 / FS).cos())
3    .map(|ss| Complex::new(ss, 0.0))
4    .collect();

Plot of a cosine function with a frequency of 3 Hz and an amplitude of 7 and a function yelding only zeros.-8-6-4-20246800.20.40.60.811.21.41.61.82cos(2πft)zero

Further reading/references