Skip to the content.
2024/12/14

And now for something completely different!

Source code here

I started learning Rust about 3 years ago, the old fashioned way: reading, writing toy programs with what I understood, reading more, writing more, and so on. Then I got a job writing Go and Python, and set Rust aside.

For some Thanksgiving entertainment, I gave myself a task:

  1. Write a mod player
  2. In Rust
  3. Using Copilot for help

I liked this idea a lot because I have no experience with audio processing, no real experience with Rust, and at my day job, I haven’t found AI assistants to be very useful. This project opened up three avenues of learning opportunity!

Plus, writing a mod player is non-trivial - it’s a fully fledged multi-channel sampled music format with special effects. It’s more than just a toy learning task!

tl;dr

After about a week I had a working Rust implementation that plays my reference module fairly accurately.

By the time I got a reasonably working player, I was feeling comfortable with the basics and enjoying writing Rust code! Cool!

Assistant to the Regional tl;dr

If I was not already an experienced programmer1 I doubt I could have made such fast progress, even with an AI assistant, particularly in a new language, most particularly in Rust2.

The Approach

Plan to throw one away, you will anyways. - Fred Brooks

Eyes on the prize

I wanted to play a reference module and recognize the tune. If the code was sloppy or worked for the wrong reasons, that was OK. The module file format is not trivial, there are lots of special musical effects that artists apply to their songs. But at a very basic level, I expected to be able to play the file’s samples at the specified notes and tempo, and recognize the song.

Antything that didn’t drive me directly to that end was out of scope.

Fake it till you make it

I hardcoded lots of stuff up front. Song tempo is encoded in the module file, but I hardcoded that. I hardcoded the number of frames to play in my reference song. Later, I learned how to detect song end, and I removed my hack.

I aimed for “breadth first” and hardcoded any “depth” that was impeding my progress.

What? No Tests?

This was a prototype, in a language that’s new to me, in a domain where I have no experience. Since I didn’t know much about Rust, I knew I’d be producing bad code for a while. And that turned out to be true! By the end of the week I knew how to receive Traits in my methods, but I sure didn’t know how to do that at first, so the code was pretty messy.

I had to choose my focus, and I chose motivation. I wasn’t going to be motivated during Thanksgiving travels/visits to write Rust unit tests, but I would be motivated by the dopamine hits of getting closer and closer to an accurate rendition of my test songs.

Time spent writing unit tests that I’d throw away as soon as I learned a new language feature was time I could be using to validate my understanding of the problem by listening to the audio output of my program.

Finally, the .mod format is definitely not standardized. This prototype was my way to discover inconsistencies and quirks in the set of files I care about, and let that drive my post-MVP design.

Okay, so what, then?

I worked in very small steps, using what I knew of the Rust language at that point to validate the work I’d done.

Each increment was an experiment that moved me incrementally closer to the goal, but also produced a lot of experimental code. That’s fine, I committed the experimental code, then deleted it in the next commit.

How to find bugs in an unfamiliar language

Here’s some code that Copilot wrote for me early on, before I had much understanding of rodio, Traits, Cursor, or PCM.

struct RawPcmSource {
    samples: Cursor<Vec<u8>>,
    sample_rate: u32,
}

impl Iterator for RawPcmSource {
    type Item = f32;

    fn next(&mut self) -> Option<Self::Item> {
        let sample_byte = self.samples.get_mut().pop()?;

        let sample_byte = sample_byte as i16; // Convert to i16 for arithmetic
        let sample = (sample_byte - 128) as f32 / 128.0; // Perform the operation

        Some(sample)
    }
}

When I played samples using this code, they were obviously, completely wrong. But the nature of the wrongness was not obvious. So I used this code to play a few samples until I got to one that was supposed to be a snare drum. Then the problem was obvious: the samples were being played backwards.

Since I was making only small incremental changes after verifying some success, I traced the problem to this bit of code.

        let sample_byte = self.samples.get_mut().pop()?;

This code plays the sample in reverse, popping bytes off the end of the sample instead of from the beginning.

To fix the problem, I changed the Vec to a std::collections::VecDeque and the .pop() to a .pop_front(). That fixed the immediate problem, but was clearly a dumb solution. Later, I realized the Cursor wasn’t actually useful, and replaced it with a simple index into the Vec. That was important because I needed to loop samples. Looping samples becomes as easy as self.sample_idx = 0, no bothering with Cursors that don’t implement Copy!

Lessons learned

  1. I really like writing software. I knew this already but this was a fun reminder!
  2. For this project with lots of known and unknown unknowns, I made rapid prototype progress by leaning on an LLM
  3. The LLM introduced subtle bugs. In this case, the project was low stakes and debugging the bugs actually helped me learn Rust! If this were for professional use in a language I know, I’d hesitate to use it.
  4. Compared to other programming languages I’ve learned in the past, I believe Copilot accelerated my learning by giving me working bad code instead of the usual non-working bad code we produce while learning. This let me work on a broad project instead of drilling down into narrowly scoped language features, losing motivation and momentum.

Isn’t this all just a giant pile of tech debt?

  1. Yes, it sure is.
  2. Learning a new language is tech debt. You’re not an expert right out of the gate.
  3. The mitigation was identified by Fred Brooks before I was born: Plan to throw one away. When learning a language, the best you can hope for is a giant pile of tech debt. You don’t know what you don’t know, so if the code is actually going to matter (in this case, it isn’t!) you must plan for a full rewrite once you understand what you’re doing!
  1. Humblebrag. 

  2. Rust isn’t difficult, particularly if you’ve been repeatedly footgunned by the problems it aims to solve. But it’s not a simple language, when compared to languages that took simplicity - not safety - as a design goal.