pre-pre-pre-preambles (Part 4/12) 🕊️

Paul Tagliamonte 2026-09-08 diy
🕊️ This post is part of a series called "Pigeon". If this is the first post you've found, it'd be worth reading the intro post first and then looking over all posts in the series.

If 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.

TermValueDescription
Sequence Length16IQ values to set in the frequency-domain
Order4Number of times per block the signal repeats in the time-domain
Count2Number 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.

⏳ Need a crash course on what "time domain" and "frequency domain" mean and how to work with them with SDRs? I wrote up a short summary here to help.

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!

Let’s talk about finding a preamble in an IQ stream →