Skip to content

Demod 2400x - #330

Closed
CharlesBaud wants to merge 25 commits into
flightaware:masterfrom
CharlesBaud:demod-2400x
Closed

CharlesBaud wants to merge 25 commits into
flightaware:masterfrom
CharlesBaud:demod-2400x

Conversation

@CharlesBaud

@CharlesBaud CharlesBaud commented Oct 19, 2026 •

Copy link
Copy Markdown

PR checklist

  • I certify that this PR meets the requirements of the DCO
  • This PR has a base branch of dev (not master)
  • This PR does not contain generative AI output

CharlesBaud and others added 25 commits October 17, 2026 16:32
Pass the preamble threshold to locate_preamble(); a metric below it is
stored as 0, tested exactly as n < thres * d, so the divide is skipped.
The peak search gives the same results (a neighbour below the threshold
is below the peak either way). NEON/AVX2 skip the divide when all 8 lanes
are below (96% of vectors on noise). thres = 0 gives the old metric.

aarch64 NEON 1.64 -> 1.22 ms, ARMv7 NEON 2.66 -> 1.34 ms per buffer;
dump1090 demod ~1574 -> ~1495 ms on the test file, identical frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The x86_avx2 flavour is built with -ffast-math, so the compiler may replace
_mm256_div_ps / sqrt with reciprocal estimates. The truncated result can then
be one too small, which the one-sided check missed (locate_preamble failed
verification on real Intel hardware). Use two-sided checks in the AVX2 divide
and sqrt and in the scalar demod_isqrt, as the ARMv7 code already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AVX2 is now fastest for locate_preamble too (it was last in the old,
pre-vectorisation timings), so production x86 builds use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bestPhase() picks the frame position by bit quality, which is flat across
about 3 x 12 MHz samples on a clean pulse, with ties going to the earliest.
Used as the timestamp, that gave a 0..2 sample bias depending on the
sub-sample phase and a bimodal +-1 sample jitter in noise.

After a frame is accepted, fineTiming() correlates its data chips (from the
corrected message) with the upsampled signal at pos-2..pos+2 and timestamps
the best offset. On synthetic frames at known 12 MHz ticks, all 5 phases,
the timestamp is now exact (stock: 0..2 ticks bias, sigma 0.41-0.48 ticks).
Decoded messages are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upsampler: each of the 5 phases is a least-squares fractional-delay fit
(delay 3.1 + 0.2p input samples, flat over 0..1.0 MHz), rounded with a
common offset so every phase sums to exactly 112. Against the windowed
sinc it matches delay and gain across phases (group delay error <= 0.046
samples to 0.8 MHz) with lower noise gain (0.94 vs 1.04).

DATA_THRES 80 -> 65: more messages at no CPU cost and no extra tracks.

60 s Pi 4 RTL-SDR capture (--fix): 83910 -> 84366 usable messages.
Weak synthetic frames (+-100 kHz offset) decoded out of 2000:
noise 300: 1205 -> 1405, noise 350: 454 -> 585. Timestamps stay exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
demodulate2400x() never called adaptive_update(), so --adaptive-range and
--adaptive-burst got no noise floor or loud-burst input. When either is on,
compute 2.4 MHz magnitudes (stock magnitude_sc16q11 kernel, stock scale) and
feed them as demod_2400.c does: gaps between frames, then each frame with
its message, carrying a frame that runs into the overlap to the next buffer.

Signal power per sample is capped at full scale, as the stock magnitude
kernels clamp, so message power can't exceed 0 dBFS (was up to +1.1 dBFS
on clipped frames).

60 s Pi 4 capture, fake gain table on --ifile: same adaptive decisions as
stock (range+burst: same 4 gain steps), noise floor -34.1 vs -34.5 dBFS;
per-message RSSI vs stock: mean +0.01 dB, sigma 0.20 dB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shows in receiver.json (and so on the map) and --help, to tell this build
from the packaged dump1090-fa 11.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The upsampler phases are mirror images (phase 4-c is phase c reversed,
phase 2 symmetric), so NEON and AVX2 form u[k] = x[k] + x[7-k] and
v[k] = x[k] - x[7-k] and compute each phase pair from shared sums: half
the multiplies. Exact integer arithmetic, so the output is identical to
the direct form; both fall back to it if the table isn't mirrored.
Generic keeps the direct form as the reference.

Rounding shift 5 -> UPSAMPLE_SHIFT 4 in all implementations, one more bit
for weak signals. UPSAMPLE_ABS_SUM (largest sum of |taps|, 212) and a
_Static_assert keep the power I^2 + Q^2 below 2^31 (max 1.47e9).

starch-benchmark: upsample12 also runs on full-range data (uniform
-2048..2047 and per-phase worst-case windows, magnitude up to 38371 of
38376), and the generic reference is checked in 64-bit arithmetic.

Pi 5: upsample12 NEON 2.48 -> 1.54 ms per call; 60 s capture demod
5.9 -> 4.8 s, 84369 messages (84366 before). Validated on aarch64,
ARMv7 and x86 (QEMU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decodeShortAdbsData() (5 calls per preamble candidate) becomes a wrapper
around a new starch kernel, decode_bits(p, nbytes, msg, quality):
generic (the old loop), NEON (vld2 splits the chips, vcgt + weighted sum
packs a byte, vabd/vpadal for quality and power) and AVX2 (one 256-bit
load per byte as 32-bit (a, b) lanes, cmpgt + lane reverse + movemask).
Quality goes out through a pointer so starch-benchmark can verify it.
Benchmark: 7 bytes on ordinary data and 2048 bytes on full-range data.

7 bytes: Pi 5 NEON 26 ns vs 82 ns generic; ARMv7 NEON 65 vs 102 ns.
60 s capture demod 4.8 -> 4.3 s; raw output with timestamps identical.
Validated on aarch64, ARMv7 and x86 (QEMU); x86 wisdom provisional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the branch's demodulator does, and stock vs DEMOD_2400X results on a
60 s Pi 4 RTL-SDR capture: messages overall, by signal strength and by
distance, MLAT timing on synthetic frames, and CPU on Pi 4 and Pi 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same least-squares design (gen/ls_coeffs.py 1.0 128) at gain 128 instead
of 112: less rounding error, group delay error between phases 0.046 ->
0.028 samples. Largest tap 126; UPSAMPLE_ABS_SUM 244 keeps the worst-case
power at 1.95e9 < 2^31 with UPSAMPLE_SHIFT 4 (checked by the static assert
and the full-range benchmark).

60 s capture: 84389 usable messages (84369 at 112); timestamps exact.
Validated on aarch64, ARMv7 and x86 (QEMU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README.md: short DEMOD_2400X section linking to:
* README-demod2400x.md: design outline and results (figures from the
  current build)
* NOTES-demod2400x.md: development notes, decisions with measurements,
  what was tried and dropped, testing and gotchas
* AI-DISCLOSURE.md: use of generative AI on this branch
* sim/: upsampler coefficient generator (ls_coeffs.py), synthetic test
  signal generator (gen_timing.c, needs liquid-dsp) and timestamp checker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same build as the GitHub workflow: gcc 14 and clang 19 on AArch64 build
without warnings and pass make test, with byte-identical decoder output;
ARMv7 and x86 cross-builds (gcc, clang) pass starch-benchmark -V. Notes
that clang turns every AVX2 divide/sqrt into a reciprocal estimate, which
QEMU can't reproduce, so the newest AVX2 kernels still need an Intel run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NOTES-demod2400x.md gets a Test data section: the SDRplay SC16 recording
used for early development, the 60 s RTL-SDR recording from the Pi 4 /
FlightAware Pro Stick Plus, synthetic frames from sim/, and the live
receiver. Results and AI disclosure say which data each comparison used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s input

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CharlesBaud

Copy link
Copy Markdown
Author

Will create branch from dev but want to work on coherent demodulation if magnitude demod fails.

@mutability

Copy link
Copy Markdown

This looks really interesting!

@CharlesBaud

Copy link
Copy Markdown
Author

Following up on this: the work now lives on a branch based on dev, with the
coherent second pass I mentioned. It isn't a PR yet; I'd welcome you trying it
before I open one.

Branch: https://github.com/CharlesBaud/dump1090/tree/demod-2400x-dev

git clone -b demod-2400x-dev https://github.com/CharlesBaud/dump1090.git
cd dump1090 && make RTLSDR=yes SOAPYSDR=yes

What it is: an alternative 2.4 MHz demodulator. The I/Q is upsampled to
12 MHz with a polyphase filter, there's a preamble search at every 12 MHz
position, and arrival times are refined for MLAT. A coherent second pass then
uses the carrier phase to rescue frames that fail the CRC. The heavy parts are
starch kernels (generic, ARM NEON for ARMv7 and AArch64, x86 AVX2), all
bit-identical, which starch-benchmark -V checks.

Switching it off: --no-demod2400x gives the stock demodulator, with output
byte-identical to dev (checked on UC8, SC16, SC16Q11 and --modeac), and
--no-coherent drops the second pass.

Results (60 s Pi 4 RTL-SDR capture, --throttle --fix):

Stock DEMOD_2400X + coherent
Messages 73,146 84,545 (+16%) 93,827 (+28%)
DF17 10,352 11,830 13,365
Global CPR positions 3,510 3,977 4,538
Demodulation CPU, Pi 4 4.0 s 5.8 s 7.5 s

The gain is mostly weak, distant aircraft. MLAT timestamps have no bias across
sub-sample phases, and mlat-client stays synchronised live (about 265 nearby
receivers). A Pi 3B runs it at about 39% of one core.

Docs: README-demod2400x.md (design and results), NOTES-demod2400x.md (a
"For reviewers" section listing every change to existing files),
COHERENT-demod2400x.md.

Generative AI: much of the code and text was written with Claude Code to my
design and under my review and testing. AI-DISCLOSURE.md says which parts.

You and others are very welcome to try this. I have tested on PI5 PI4 PI3. I did not want to create a pull request yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants