Skip to content

Architecture Overview

Module Map

Path What lives there
main.c app_main, task launch, orchestration
display/ display driver, BSP init, LCD + touch
ui/ LVGL widgets, spectrum/waterfall canvases, FT8 screen
cat/ USB CDC-ACM CAT polling, QMX control
audio/ USB UAC consumer (core 0, polling mode)
dsp/ FFT, spectrum, I/Q balance, CW demod
ft8_tx.c FT8 TX engine, arm/run/abort, tone finder
ft8_test.c FT8 RX: slot loop, capture, decode, state machine
ft8_sim.c FT8 simulation mode (phantom stations)
ft8_qso.c FT8 QSO state machine (pounce + CQ-run)
render/ 30 Hz render task, spectrum smoothing, waterfall
wifi/ WiFi + SNTP
net/webserver.c HTTP server, web UI endpoints, WebSocket
adif/ ADIF logging, QRZ/eQSL upload
storage/ NVS settings, config I/O
rtc/ RTC driver (RX8130CE)
time_sync/ Time orchestrator (SNTP/RTC/QMX/manual)
util/ FPS counter, diagnostic logging, bandplan
espressif__usb_host_uac/ UAC + CDC-ACM coexistence (patched)
espressif__esp_lcd_touch_st7123/ ST7121/ST7123 compatibility (patched)

Data Flow

Audio Path (Spectrum + Waterfall)

From the radio to the screenQMX USB audio - 48 kHz, 24-bit I/Qaudio_task - core 0, pollingI/Q balance correction, per sampleDC blockerring buffer in PSRAMfft_task - core 1512-point FFT, Blackman-Harris windowEMA smoothing, dB scalingspectrum mutexrender_task - 30 HzLVGL canvases - spectrum and waterfallthe audio task polls rather than waiting on events: event-driven reads truncated the UAC chunks andpumped the noise floor

FT8 Receive Path

From the slot boundary to a row on screenRX slot boundary - every 15 s, UTC-alignedft8_task on core 1dsp_ft8_capture() - runs TO the next UTC boundary, ~180000 samples at 12 kHzstreaming STFT builds the waterfall DURING capture, into PSRAMcandidate search - up to FT8_MAX_CANDIDATES (140) sync hitsdecode_candidate_range() - LDPC per candidateft8_dec0 on core 0 takes the other half of the candidates in parallelft8_screen_record_decode() then ft8_qso_advance()ft8_screen_view - the live decode listthe candidate cap is 140, not ~10: on a busy band the search hits it every slot, and most are falsesyncs that burn a full LDPC budget before failing

FT8 Transmit Path

From a tap to a keyed radiooperator taps Transmit, or the QSO machine decidesft8_tx_build_request() - build the message textft8_tx_arm() - stage it for a slotrefuses while the radio is released (see "Let me use the QMX menus")ft8_tx_run() at the slot boundarycat_poll_set_paused(true) for the whole burstTX; then 79 x TA<freq>; at 160 ms, then TA0; and RX;FT4: 105 tones at 48 ms insteadft8_qso_on_tx_complete() - re-arm the next messagethe tail always runs, even on an abort or an error, so the radio cannot be left keyed

Task Priorities

Priority 25 (highest) — app_main (setup only)
Priority 24 — WiFi / SNTP
Priority 10 — web server
Priority 6  — FT8 transmit ISR
Priority 4  — FFT (ring buffer consumer, spectrum producer)
Priority 1  — FT8 decode, CAT poll, render, LVGL, time sync

Critical: FT8 decode runs at priority 1 — lowest. This ensures real-time tasks (FFT, USB) never starve, and the UI thread remains responsive.

DSP Pipeline

Spectrum Calculation

Ring buffer to canvasring buffer - 64 KB in PSRAM, 16384 I/Q pairs, about 1.4 s at 12 kHzFFT - 1024-point complex, Blackman-Harris by defaultmagnitude, then scaleddBm = 20*log10(mag) - 148 dB calibration offsetflat = 20*log10(mag / per-bin floor)spectrum mutexrender_task at 30 Hz - EMA smoothingLVGL canvas pushthe FFT window is selectable in the drawer: Blackman-Harris, Hann or Nuttall

Details worth knowing, each of them load-bearing:

  • FFT — 1024-point complex. The esp-dsp ANSI fallback is used deliberately: the PIE/vector build crashes under sustained WebSocket load on this silicon.
  • IF offset — the QMX presents I/Q at +12 kHz, so the spectrum, waterfall and S-meter all shift bin selection by n_bins/4 to put the VFO signal at the visual centre. Miss that shift and the S-meter reads the DC/LO spike instead of the signal.
  • DC blocker — a one-pole IIR on the I/Q stream, ahead of the FFT.
  • Spectrum smoothing — per-bin EMA, α = 0.4 by default and adjustable in the drawer.
  • dBm calibrationDSP_DB_CALIBRATION_OFFSET = −148.0 dB, measured on a dummy load: the noise floor reads −130 dBm and S9 = −73 dBm.
  • Waterfall scroll — a 1280×824 double-height canvas, so a new row costs about 130 µs instead of the ~92 ms a memmove would take.
  • Audio task — polling on core 0 with a drain loop, not event-driven. Event-driven reads returned truncated UAC chunks that saturated the FFT input and pumped the noise floor on a slow ~13 s cycle.

I/Q Balance (Gram-Schmidt)

Per-sample correction applied in audio.c before ring buffer:

Blind adaptive correction, per sampleI_in, Q_in - core 0, 48 kHzDC tracker, tau = 1 sI_out = I_in - I_dc, Q_out = Q_in - Q_dcpower and cross-product trackersK_amp from the I and Q powers, tau = 200 msK_phi from the I*Q cross product, tau = 1 scorrection - Q_final = (Q_out - K_phi * I_out) * K_ampring buffer pushI is never touched; the whole correction lands on Qall constants run 8x faster for the first 2 s after a reset, so it converges in about 125 ms

Waterfall

Why the waterfall scrolls without moving memorydsp_ft8_capture() during FT8 RXSTFT while the slot is still being capturedper-bin PSRAM buffer, rolling windowrender_waterfall_tick()1280 x 824 canvas - twice the visible heightthe new row is written at s_wf_head AND at s_wf_head + WATERFALL_Hthe VIEW pointer moves; the pixels do notcolorise with the SDR gradient, then push to LVGLthis is the trick: a memmove per tick costs ~92 ms, moving the view pointer costs ~130 us

FT8 Decode List

From a decode to a row you can tapft8_screen_decode_queue - 4-buffer poolft8_screen_record_decode()call, grid, SNR, audio tone, timeft8_screen_get_all() - snapshot for the UIexpire anything not re-heard within 60 sapply the include / exclude / worked-before filterssort - CQ callers and stations answering us firstft8_screen_view.c - the LVGL listown-call rows inverted, red fill and white textthe list is a picture of who is on frequency NOW, not a log: a station that stops transmitting dropsoff after a minute

Storage

NVS (Non-Volatile Storage)

Settings namespace: QMX

Key             Type     Example
wifi_ssid       string   "MyNet"
wifi_pass       string   "password123"
callsign        string   "OZ1LAV"
grid            string   "JO45"
last_freq_20m   uint32   14074000
last_mode_20m   uint8    2 (USB)
memory_1        blob     { freq, mode, bw, name }
...
diag_log        uint8    1 (on/off)

ADIF Log

File: /spiffs/qso.adi

Format: Standard ADIF (one QSO per <EOR> record). Each QSO stores:

CALL, GRIDSQUARE, RST_SENT, RST_RCVD, QSO_DATE, TIME_ON, FREQ, BAND,
MODE (+ SUBMODE on FT4), STATION_CALLSIGN, MY_GRIDSQUARE,
MY_SIG/MY_SIG_INFO (our activation), SIG/SIG_INFO (theirs), ...

Downloaded via web UI or serial.

Timing & Slots

FT8 Slot (15 seconds)

One FT8 slot, from the UTC boundaryCaptureDecode02.813.2150 s - boundary. ft8_task wakes, checks for an armed TX, then dsp_ft8_capture_begin()0-2.8 s - an armed reply fires on THIS slot, not the next one (FT8_REPLY_TX_WINDOW_MS)13.2-15 s - capture is cut 1.8 s early (FT8_DECODE_RESERVE_MS) so the candidate search and LDPCfinish BEFORE the boundarythe signal itself ends at 12.64 s - 79 symbols x 0.16 swith Fast pounce off, capture instead runs to the boundary and the decode lands in the next slot

UTC Boundary Alignment

The capture window is UTC-clamped, not fixed-sample-count. Actual window length is computed:

ms_to_boundary = 15000 - (sys_time_ms % 15000)
dsp_ft8_capture(timeout = min(ms_to_boundary, SLOT_TIMEOUT_MS))

If samples run short before the boundary, DSP zero-pads the rest. This keeps the capture window locked to UTC, preventing the multi-slot drift problem seen in earlier versions.


Next: Read CLAUDE.md for the detailed quirks and critical knowledge, or contribute via Contributing.