This is part one in hopefully a much longer series of posts about these topics. I am trying to learn Rust, and re-learn a lot of the Digital Signal Processing stuff that I read at University, so these posts are my way of trying to cement some of this knowledge in my head as I’m learning. I hope that they are interesting and useful for you too.

Continuous and Discrete signals

In mathematical terms a signal is a function that describes the change of a phenomena in relation to a domain. You can visualise these by plotting them as graphs, typically the x axis shows the domain, and the y axis shows the amplitude of the phenomena you are measuring, for example, this could be loudness, or pressure.

There are two main types of signals: continuous signals and discrete signals.

Continuous signals have a domain that is a continuum, or an uncountable set. Often this domain is time, which gives us continuous-time signals. Sound waves are an example of a continuous-time signal.

Other examples of continuous-time signals are: your speed during your walk to work, or the level of light shining through your bedroom window throughout the day.

Computers are really bad at dealing with continuous signals, because to do so implies that you can handle and manipulate infinite, continuous data; something that is impossible to do in digital systems. In order to manipulate and represent continuous signals, like sound, on a computer we need to convert them to the second type of signal: discrete signals.

A Discrete signal is a signal where the change of a phenomena that you are measuring is sampled at discrete points in the domain. What this means for our continuous time based signals is that we have to measure the phenomena at predefined points in time, and when we plot these points (assuming we have measured at the correct rate), we can reproduce the original continuous signal. This process is called Sampling (which I’ll try and cover more fully in another post.

Programming a Sine wave

In order to see some of this in action let’s write some code. As these posts are primarily intended as a learning aide to help me understand DSP and Rust I’m going to go ahead and create a new project using Cargo, the Rusts standard build tool.

cargo new --bin dsp-with-rust

I’m using the --bin flag to tell cargo that this project will produce a runnable binary rather than a library, mostly because I want to be able to use cargo run to execute my examples as I go.

I’m going to generate a discrete sinusoid, but more interestingly I’m going to write this sinusoid to a WAV file so that we can listen to the result!

I don’t want to have to work out the specifics of the WAV file format, as that doesn’t seem massively relevant at this point, so I searched on crates.io for “wave” and found the Hound crate.

Crates are the Rust equivalent of packages or libraries, sort of like Ruby’s gems or Python’s eggs.

First we’re going to open up Cargo.toml and add in Hound as a dependency of our project. Add the following lines to the bottom of the file

[dependencies]
hound   = "1.0.0"

My Cargo.toml now has two sections separated by a new line: a [package] section that contains a bunch of information about the project, and the [dependencies] section above.

Running cargo build now successfully builds both my dsp binary and the hound library, which it fetches from crates.io

[master] [matth@tomoe] ~/.../dsp-with-rust % cargo build
    Compiling hound v1.0.0
    Compiling dsp-with-rust v0.1.0 (file:///home/matth/code/projects/dsp-with-rust)

Running this doesn’t do anything interesting yet. The next step is to link our binary with the Hound crate, and import the modules into our local scope so we can use them. We do this in src/main.rs by putting the following lines at the top of the file

extern crate hound;

use hound::*;

Now we can start to build our wave. Let’s set up some constants that we’re going to need, and try and open a WAV file for writing.

    fn write_sine_wav() {
        let sampling_freq = 44100;           // 44.1 kHz, a common sampling frequency
        let sampling_bits = 16;              // 16 bits per sample, a la CDDA
        let amplitude     = i16::MAX as f32; // Make it loud
        let note_freq     = 440.0;           // A440, the A above middle C.
        let length        = 2;               // length of the sound in seconds

        let spec = hound::WavSpec {
            channels: 1,
            sample_rate: sampling_freq,
            bits_per_sample: sampling_bits
        };

        let maybe_writer = hound::WavWriter::create("sine.wav", spec);
    }

A lot of these values are taken from the Wikipedia page for the CD-DA standard.

The reason I have named the variable in which I’m storing the WavWriter object maybe_writer is because this constructor returns a Result object. A Result object is an enumerable type, this is a composite type that can be one of several values. The difference between Rusts enum types and C data types is that Rust can attach arbitrary data to each of the types, which is pretty neat.

A Result type can be one of two values, Ok, or Err and we can attach data to each of these values. This allows us to use it with Rusts pattern matching constructs to really neatly handle error cases.

    match maybe_writer {
        Ok(writer_obj) => {
            ...
        },
        Err(e) => {
            println!("Oh Noes!");
            println!("{}", e);
        }
    }

In this case we’re matching over our maybe_writer object. If the result is Ok then we can pull out the actual hound::WavWriter object that’s contained inside and we can use it.

If the maybe_writer is an Err object, then the data within the Err is the actual error message that we can display to the user.

Bringing this back to our discussion of discrete and continuous signals, a continuous sine wave is a smooth, repetitive curve defined by the function

where:

  • \(A\) = the amplitude of the signal (amplitude in the code above)
  • \(f\) = the frequency, this is the number of complete repetitions per second (note_freq)
  • \(t\) = the time at any point along this curve (we’ll work this out later)
  • \(\varphi\) = The phase of the curve. When \(t = 0\) this is the offset along the \(x\) axis and can be used to shift the curve left or right (0 in this case as we just want an unshifted Sine wave).

To sample this and create a discrete representation of our sine function we just need to calculate the value \(t(t)\) at a sufficient number of points in time. This is where our sampling frequency comes in.

We need to know how many samples to take to accurately represent our Sine wave digitally. Again sampling frequency tells us that 44.1kHz is the best rate to use to accurately reproduce all the frequencies audible to the human ear.

This means that to calculate the total number of samples we can multiply the sampling frequency by the length in seconds that we want our final wave/audio file to be.

The next thing we need is the actual timestamps that our samples will be sampled at. As this is going to result in a collection of 88200 values between 0 and 2 we need to do this using floating point numbers (as f32 is Rusts way of casting our integer values into a 32 bit floating point representation).

The code that translates these values is pretty cool, the first part: (0 .. no_of_samples) is a Range, it will expand into an Iterator that will, when asked, count from 0 up to no_of_samples. We then call map on this Iterator. map is a function that will apply a function to each of the values in a collection, and will return a new collection containing all of the calculated values.

In Python we might do something like the following:

    def add_one(t):
        return t + 1

    values = (1, 2, 3)

    new_values = map(add_one, values)
    # new_values would be (2, 3, 4)

This is close to what we’re doing in Rust, only we’re passing an anonymous function as an argument to the map function.

We map over each of the values in our range, passing each value as the variable x to our anonymous function which divides it by our sampling frequency to get a normalised position along the x axis.

    let no_of_samples  = sampling_freq * length;
    let normalized_sample_indices = (0 .. no_of_samples).
        map(|x| x as f32 / sampling_freq as f32);

Once we’ve got our list of times (which are the domain values for the \(x-axis\)), we can use the sine function above to calculate the \(y\) points, by substituting our sample index for \(t\) in the formula.

    let mut writer = writer_obj;
    for t in normalized_sample_indices {
        let sample = (t * note_freq * 2.0 * PI).sin();
        writer.write_sample((sample * amplitude) as i16).unwrap();
    }

Hopefully it should be easy to see that we’re calculating \(A\sin(2 \pi f t)\) for each value of \(t\) in our sample collection and then writing it to the WavWriter in this code snippet, but some of the other Rust’isms might require explanation.

Every variable in Rust is immutable by default, and because the WavWriter object needs to keep track of the number of bytes written to the file, the write_sample function is mutating data inside the writer. This means that we need to take a mutable copy of the WavWriter object; this is what the first line achieves, we can specify that a variable is mutable by using the mut keyword.

the other thing that’s worth looking at is the call to unwrap on the result of the write operation. If we omit this call and try to build our program we get the following warning.

  Compiling dsp-with-rust v0.1.0 (file:///Users/matth/code/projects/dsp-with-rust)
src/main.rs:31:17: 31:66 warning: unused result which must be used, #[warn(unused_must_use)] on by default
src/main.rs:31                 writer.write_sample((sample * amplitude) as i16);
                               ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
note: in expansion of for loop expansion
src/main.rs:29:13: 32:14 note: expansion site

This warning occurs because the Result type in Rust is annotated with the #[must_use] attribute. This github issue explains the use case of this annotation, which is to prevent the unintended consequences that can arise from ignoring return values, especially when working with I/O objects.

In this case I am being a little lazy, as I don’t really care about the return value. I probably should be more rigorous with my error checking, but for this example we can just consume the Result object and get the data out from inside it, which is what is achieved using the call to unwrap().

Another way of doing this would be to assign the Result object to the variable _, ie.

let _ = writer.write_sample((sample * amplitude) as i16);

This can be more intention revealing in some situations, as using _ is a rust idiom from the pattern matching implementation that means “I don’t care about this value”, it’s used as the default branch in a match statement and will match anything that hasn’t been explicitly specified. So by setting our value to _ we can declare our intent to never use this value again.

I chose to use unwrap as it serves the same purpose in this example, and I found that having 2 lines that start with the let keyword distracted me into thinking about variable declaration instead of the actual purpose of the code, which is to write a sample to the WavWriter. I think this is subjective and it depends on whoever is reading the code as to which variant will make the most sense.

The last thing we need before we can actually run this code is an entry point into the program. Following on from the C tradition, Rust uses a function called main for this:

fn main() {
    write_sine_wav();
}

Now that we’ve got everything written, we can run the program. Remembering our brief intro to Cargo above, we can build and run our program with the single step cargo run.

Doing this should give you a file in the main project directory called sine.wav that should sound like a constant tone (the A note above middle C) for 2 seconds in duration.

Which is awesome, we’ve proved that by writing mathematically calculated values at discrete points in time, we can successfully produce audio. Next we’ll have a look at how we modify this program to plot our signal as a graph.

Having a search through crates.io shows me that there’s a Rust crate available for gnuplot

Let’s add gnuplot = "0.0.17" to the dependencies section of our Cargo.toml file. You’ll also need to make sure you have gnuplot installed on your machine. For me that’s achieved with a quick sudo dnf install gnuplot but that will depend on what OS you’re running. Next we can link the Gnuplot crate with our binary and import the modules into our scope just like we did with the Hound crate above.

The Gnuplot crate assumes a certain familiarity with the syntax of the Gnuplot library so a lot of the resultant code is similar to what you’d write using Gnuplot on the command line.

What this means is that I’m going to gloss over a lot of it!

The Gnuplot crate exposes ways to create various different types of plots, the most sensible for this purpose is the 2D line plot; from the documentation of the lines method we can see that we need to create 2 iterators to pass in, one containing our \(x\) values and the other containing our \(y\) values.

Iterators in Rust are implemented by Traits. Traits are language features that allow types to implement known and consistent behavior. A lot like Interfaces in some Object Oriented languages.

So we’ll create 2 collections that implement the Iterator trait, using Rusts Vector type, this is Rusts standard grow-able collection type - it’s an array, but you don’t have to set a size explicitly up-front.

Although in this case, we are going to set the size of the collections at definition time, because we already know how many samples we’re dealing with (and as we’re plotting a discrete time signal we want our \(x\))’s and our \(y\)’s to match up.

let mut xs: Vec<f32> = Vec::with_capacity(no_of_samples as usize);
let mut ys: Vec<f32> = Vec::with_capacity(no_of_samples as usize);

We’ve defined these collections as mutable, so that we can add stuff to them later, and their type signature is Vec<f32> to indicate that these are Vectors that contain f32’s, or 32-bit floating point numbers.

Now that we have our holding collections, we can add our values to them within our main loop. We already have the \(x\) and \(y\) values in question because we’re writing them to the Wav file, so we can just append these to the collections each time through the loop.

for t in normalized_sample_indices {
    let sample = (t * note_freq * 2.0 * PI).sin();
    xs.push(t);
    ys.push(sample);
    writer.write_sample((sample * amplitude) as i16).unwrap();
}

And once our collections are complete we can use Gnuplot to plot them.

let mut fg = Figure::new();
fg.set_terminal("png", name);
fg.axes2d().
    set_title("Sine wave - 44.1kHz", &[]).
    lines(xs.iter(), ys.iter(), &[Caption("sine")]);
fg.show();

That code tells Gnuplot to generate a 2 dimensional line graph (notice that we’re calling iter() to get an actual Iterator object from our Vectors). We’re using the whole collection though, and given there are 88,200 samples, from our 44.1kHz sample rate it looks a little busy.

Sine Wave, all of the samples

There is a sine wave there I promise! We can see it more clearly if we take a much smaller number of samples. Let’s filter down our values to just the first 110 using the take() method in the Iterator trait

xs.iter().take(110)

That gives us something that looks roughly like one whole cycle of our wave.

Sine Wave, one cycle

In summary: we’ve looked at Continuous signals and Discrete signals, we’ve seen that we can use the sine function to mathematically generate tones, and we’ve examined what those tones look like when you plot them against time. We’ve also explored Rust a bit; we’ve seen how Cargo, the included build tool, helps us to set up projects and manage dependencies. We’ve seen Rusts helpful error messages, immutable variables and touched on the Trait system.

The last thing that I want to write about; an addendum of sorts, is a small refactoring to reduce some duplication in our code.

We’re generating two plots in this post, and everything I’ve written so far assumes that we’ve just copy and pasted the Gnuplot specific stuff and changed our variable names. This is a great place to start but now that we know it works we can extract that code into a method. Now at our call sites we can have something like this:

draw_plot(xs.iter(), ys.iter(), "sine.png");
draw_plot(xs.iter().take(110), ys.iter().take(110), "sine2.png");

I’ve extracted the body of the code out into a method and am passing in only the things that change as arguments. In this case xs, ys and a name for the file to write our plot to.

The draw_plot method looks like this:

fn draw_plot<K, T, S>(xs: T, ys: S, name: &str)
	where K: DataType,
		  T: Iterator<Item=K>,
		  S: Iterator<Item=K>
{
	let mut fg = Figure::new();
	fg.set_terminal("png", name);
	fg.axes2d().
		set_title("Sine wave - 44.1kHz", &[]).
		lines(xs, ys, &[Caption("sine")]);
	fg.show();
}

There’s a lot going on here! The body of the method is the same code that we had before so that’s not very interesting; the real meat is the function definition, which is where we see a lot of Rusts type system come into play. This post about reading Rust function signatures is a really great intro to this stuff, and it really helped me understand what was going on (also thanks to the folks on the Rust subreddit for helping me out with a misunderstanding).

So this signature uses generics to say that this function takes 3 arguments:

  • xs which must be an Iterator over objects that are DataType’s. DataType is a trait that is implemented inside the Gnuplot crate that we have to use in order to plot things. the Gnuplot crate implements DataType for a lot of the core Rust number values
  • ys which must be an Iterator over objects that are DataType’s
  • name which is a reference to a str. This is a core type implemented by Rust that represents a statically allocated string

There is another way you can write this without the where section, which I started with as it was more natural to write initially, but I quickly refactored to use where for readability:

fn draw_plot<K: DataType, T: Iterator<Item=K>, S: Iterator<Item=K>>(xs: T, ys: S, name: &str) { }

One big gotcha with these type annotations which may seem obvious if you’re used to working with a statically typed language but completely threw me is that T and S are separate types. You can’t do this:

fn draw_plot<K: DataType, T: Iterator<Item=K>>(xs: T, ys: T, name: &str) { }

unless xs and ys are exactly the same type. I ran into problems when I had filtered xs (returning a Filter) and not ys. My definition states that xs and ys are the same type but I had assumed that just meant they had to implement Iterator<Item=K>.

That’s all for now. I’m not sure what I’ll cover next time, but I’d like to start looking at spectral decomposition and ways of telling what the frequency makeup of a signal is.

Thanks for reading, I hope it was useful. All the code that I’m writing for my DSP explorations will be available on Github. Please check it out, fork it & send me pull requests. I’d love to be able to improve my Rust so don’t hesitate to get in touch.