2026-06-02
TL;DR: I decided to try to use Goertzel filters for tone detection in the receiver. They appear to work well in host-side testing, but I can’t test them on the Nano yet because I need to make the ADC go faster. I continue to be mostly adrift with respect to DSP knowledge, and I think I’m going to need to dedicate time to learning more fundamentals. I also experimented with using AI more heavily in my workflow and found it to be a helpful tool when well-constrained.
Last time I spent a few paragraphs patting myself on the back for coming up with a solution that relies on counting zero crossings. I still think that solution is neat, but it seems like Goertzel filters might be more reliable for a similar compute cost. I expect that I may change my mind again as I learn more.
As for what a Goertzel filter actually is, I would like to refer you to a solid introductory resource, but so far I haven’t been able to find one. My best reference right now is this page which explains how to build it but not why it works. This lack of understanding makes me uneasy and has prompted me to start reading this book.
In the context of the receiver’s goal, a pair of Goertzel filters can in principle take the samples within one symbol period as input and estimate their power at the mark and space frequencies. The symbol decision is then a simple comparison between the accumulated energies of the filters. Initially I wanted to run the filters continuously (that is, across symbol periods without resetting their states to zero), but this made them too slow to respond—again, for reasons that are beyond my understanding. A fix for this seems to be to simply reset them on a fixed schedule that aligns with the symbol period, but this makes clock recovery slightly more difficult. I don’t know yet whether any of this is really going to work in practice or how optimal it is.
Naturally, when I flashed my first attempt at a partial receiver implementation to the board and ran some audio through it, it didn’t work, and I had no idea why. Getting that much code to work on the first try was a lot to ask. Debugging on the Nano is harder than debugging an ordinary host program because of compute and data rate constraints. I realized that it would be helpful if I had a test suite that could at least verify some of the logic from the host side, leaving me with a smaller set of problems that could arise when actually running it on the Nano.
LLMs are decent at this kind of stuff, so hooked up the pi agent to a local instance of
Qwen 3.6 and let it rip for a while. With feedback from me and the Rust
compiler, it was able to split out much of the receiver logic into a
separate crate1 and write tests. Then there was a
lot of back-and-forth as I reviewed the test content and pointed out
problems, which it would dutifully fix. This workflow is closer to how I
imagine software engineering might be once the AI hype cycle has
concluded. It also takes a lot less of my mental energy, which I can
then spend on more interesting parts of the problem or teaching myself
the surrounding fundamentals. (Although in practice I spent much of the
saved time relaxing by watching YouTube and playing Eve Online, which I
guess is also valuable in a different way.)
I think I’ve maintained an acceptable understanding of the code that the agent generated, but it’s definitely less than if I’d written it myself. Exactly how much less (and whether it matters) will only become clear with time, if at all. I don’t think I could have managed all this on my own in the time I had available, though.
If we assume that the tests are correct—which I’d be uncertain about regardless of their origin—then the Goertzel filters and the demodulator are both correct too. This means that if we can get valid samples and push them through this logic, then we’ll get bytes on the other end. The major parts that could still be wrong or insufficient are the ADC sampling and the general timing of the system when it’s actually running on the microcontroller.
The analog-to-digital converter on the Nano is of the sequential approximation type. I like to think of this as if I were the ADC. Suppose an unknown DC voltage appears on the input, and I want to digitize it. I have, say, ten bits of precision available, so I need to find the combination of ten switches that most closely approximates the input (with respect to the supply voltage). I can apply binary search here, starting with the most significant bit. If enabling this bit causes my output to exceed the input, then I turn it back off. Otherwise, I leave it on. Then I do the same for the next bit, and the next, and the next, until I run out of bits. The result is the value that is as close as possible to the input while not exceeding it.2
The Nano’s ADC has an internal clock that is somehow derived from the system clock but is much slower (~100 kHz). This limits the conversion speed to somewhere in the 100 microsecond range for full precision. That’s too slow for my desired sampling rate (38400 Hz), so I’m going to need to figure out how to configure the ADC to go faster and/or reduce the sampling rate. I can probably get away with a little of both. Of particular interest is the ADC’s “free running” mode, which seems to be a better fit for this application because the ADC can be more closely approximated as a truly asynchronous peripheral in that case.
I should note that part of why I’m somewhat confident about the ADC being the main problem here is that I did see some changes in the received bytes when I fed in test audio, but there were long strings of the same garbage bytes. I also counted the number of bytes processed during one minute of noise and found that it was only about two thirds of what I would expect from a 1200 Bd system. All this could be explained by the ADC sampling routine being too slow and holding everything up.
If I can get the ADC running right and feed samples into the Goertzel filters, then I should be a lot closer to a working receiver. After that, I’ll need to finish the decoder by converting bytes into frames and sending them to the host.
I wanted to make the project into a Cargo workspace, but this didn’t work because of some esoteric issues with how workspaces propagate build configuration across members. Basically the core and firmware crates have different compilation needs, so it was simpler to just put them in a regular old directory and use a path dependency from one to the other.↩︎
This isn’t the same as being the closest value overall, since there may have been a different set of bits with a larger voltage that has a lower absolute quantization error. But the sequential approach is easy to implement in hardware and gets close enough for most situations as long as there are enough bits.↩︎