pre-pre-pre-preambles (Part 4/12) 🕊️
Paul Tagliamonte 2026-09-08 diyIf you're looking for it, the intro to the PHY (layer 1) that this belongs at a high level and listing of the parts that make it up (including this one) is on the phy post.
Decoding data from RF is hard. Very hard, actually. I’m astonished that it’s become so reliable, given the data rates, and the fact that melted sand hooked up to different rocks and crystals with metal … works?
To make it a bit easier, the transmitter includes some data intended to assist with recognizing the data, and synchronizing clocks. This is called the “preamble”.
Some of you may remember we had a preamble in our BPSK
RATPACK Frame during the last
iteration of this adventure – this one is going to be a little bit more
involved. Within RATPACK, the preamble served two purposes – to determine
which phase state was a 1 vs a 0, as well as to figure out where
in the stream of continuously flowing bits a frame started. Our preamble
will do something similar – but focused on when, in the IQ stream
the burst begins, rather than in a stream of bits.

Our preamble is designed around a concept we’ve not touched on yet, something coming up soon within the post on detecting a pigeon packet. All we really need to know for now is that we’ll be using an algorithm presented by Schmidl & Cox, and that this preamble is specifically designed to repeat itself in the time domain so that that algorithm can work.
| Term | Value | Description |
| Sequence Length | 16 | IQ values to set in the frequency-domain |
| Order | 4 | Number of times per block the signal repeats in the time-domain |
| Count | 2 | Number of blocks to copy into place. |
The OFDM preamble structure we’ve chosen for pigeon is designed to repeat in
the time domain in a specific way – we need to generate a signal that
repeats order number of times within each block, and each block is
copied count number of times.
This means we can think of this block as either repeating twice – each
iFFT-generated 64 sample block repeats since it’s copied into the
preamble twice:

Or as repeating 8 times times – since each iFFT-generated block repeats
order number of times within the 64 sample block, and there are two
of the same blocks copied one after another, like so:

This allows us to do two things – first is that this enables us to detect and
align packets using the Schmidl-Cox Synchronization
algorithm due to the signal’s high degree of autocorrelation (which we’ll get
into in the next post – we’ll call the number of samples that separate
repeating samples L), and the second is to
detect frequency change by comparing time domain IQ
samples over time. Our goal is to do both, so that we have an accurate idea
of what specific IQ sample the burst started at, as well as what the difference
in frequency between transmitting and receiving radio.
Within the pigeon codebase, the preamble is specified using a Config
struct – which holds the per-mode values to generate (or detect!) a preamble
with.
struct Config {
sequence: Vec<IQ>,
order: usize,
count: usize,
}
It turns out the actual sequence doesn’t matter here – I’ve tried playing around with it a bit, but all I have are superstitions regarding its construction. I’ve tried using all of the same constant, random values, ZC sequences, and they all seem to work fine. I threw the sequence I wound up using in the post about “Mode A”, but I don’t think it’s a particularly critical detail (yet).
In order to construct the preamble, we’ll be using an iFFT, taking advantage of
harmonics in the frequency domain to produce a repeating time series signal.
What this means practically is that if we only set every orderth bin in the
frequency domain, the symbol will repeat every order samples in the time
domain. We can then take the iFFT generated time domain data, and copy it
count number of times to a destination buffer.
So let’s start off by generating the frequency-domain bins from the Config
struct above that we will use to generate the time-domain iq samples with
a helper called generate_bins.
fn generate_bins(cfg: &Config, dst: &mut [IQ]) {
let dst_len = dst.len();
let dst_mid = (dst_len / 2) as isize;
let seq_mid = (cfg.sequence.len() / 2) as isize;
for i in -dst_mid..dst_mid {
if i % (cfg.order as isize) != 0 {
continue;
}
dst[(i + dst_mid) as usize] = cfg.sequence[((i / (cfg.order as isize)) + seq_mid) as usize];
}
negative_first_to_zero_first(dst);
}
Next, let’s take those frequency domain bins and compute the iFFT, producing
IQ samples (of length block), which we copy count number of times.
fn generate(cfg: &Config, dst: &mut [IQ]) {
let cyclic_prefix_length = cfg.sequence.len() * cfg.order;
generate_bins(cfg, &mut dst[..cyclic_prefix_length]);
ifft_backend(cyclic_prefix_length)
.transform(&mut dst[..cyclic_prefix_length]);
let (prefix, prefix_write) = dst.split_at_mut(cyclic_prefix_length);
for i in 0..(cfg.count - 1) {
let start = i * cyclic_prefix_length;
let end = start + cyclic_prefix_length;
prefix_write[start..end].copy_from_slice(prefix);
}
dst.norm();
}
After that, we should have wound up with a signal that we can easily test,
the IQ samples separated by both 64 and 16 will be the same. To prove
it to myself, I wound up with a test case that looked something like this:
#[cfg(test)]
mod tests {
..
#[test]
fn test_preamble() {
let mut preamble = Samples::new(4 * 4 * 2);
generate(
&Config {
order: 4,
count: 2,
sequence: samples![
(1.0, 0.0),
(-1.0, 0.0),
(0.0, 1.0),
(0.0, -1.0)
],
},
&mut preamble,
)
.unwrap();
for (i, el) in preamble[4..preamble.len()]
.iter()
.enumerate()
{
assert_eq!(*el, preamble[i]);
}
}
}
Once that’s passing, we’re good to move on; let’s try the other end of this next – time to find one of these preambles transmitted over the air!