Pancetta Architecture
July 19, 2026 · View on GitHub
Pancetta is an autonomous FT8 ham radio station written as an 11-crate Cargo workspace. The coordinator orchestrates a real-time pipeline from audio input through FT8 decode, autonomous decision-making, and transmission — completing full CQ-to-73 QSO exchanges without operator intervention.
Crate Dependency Graph
Layer 0 — no internal deps:
pancetta-core — shared types, error handling
pancetta-audio — real-time audio I/O (cpal + ringbuf)
pancetta-ft8 — FT8 encoder/decoder/modulator/OSD
pancetta-dsp — DSP pipeline (FFT, filtering, resampling)
pancetta-config — configuration with hot-reload
Layer 1 — depends on core/ft8:
pancetta-qso — QSO management, priority scoring, autonomous operator
pancetta-hamlib — Hamlib CAT control FFI
pancetta-dx — DX cluster + PSKReporter + scaffolded LoTW
pancetta-cqdx — cqdx.io HTTP client, cache, types
pancetta-tui — terminal UI (ratatui); depends on pancetta-core and, as of
#164, also pancetta-qso (read-only use of PriorityScorer/
WorkedStationLookup to score DX Hunter rows — confirmed
non-cyclic, pancetta-qso has no dependency back on
pancetta-tui)
Layer 2 — orchestrator:
pancetta — coordinator, message bus, runtime (depends on all above)
All crates are pure Rust. There is no REST API, Web UI, or mobile layer.
End-to-End Data Flow
Audio In (USB codec, 48kHz stereo)
|
v
pancetta-audio (AudioManager, cpal + ringbuf)
| raw f32 samples via crossbeam channel
v
pancetta-dsp (DspPipeline)
| decimate 4:1 -> 12kHz mono, bandpass filter, 15-sec window extraction
v
pancetta-ft8 (Ft8Decoder)
| LDPC decode, OSD, AP injection -> Vec<DecodedMessage>
v
Coordinator (pipeline.rs)
| routes decoded messages
|------> pancetta-tui (waterfall, band activity, DX hunter)
v
pancetta-qso (AutonomousOperator + PriorityScorer)
| score stations, pick best, generate response message
v
pancetta-ft8 (Ft8Encoder)
| encode -> 8-GFSK modulate -> f32 audio samples
v
pancetta-audio -> Audio Out (USB codec)
|
pancetta-hamlib -> PTT control via rigctld (Yaesu FTdx10)
Each FT8 slot is 15 seconds. The pipeline must decode and decide within the slot boundary. Multi-stream TX is supported: N simultaneous FT8 signals can be encoded into a single slot at different audio frequencies.
Coordinator
The coordinator lives in pancetta/src/coordinator/ and is decomposed into submodules:
| File | Role |
|---|---|
mod.rs | ApplicationCoordinator struct, startup sequencing |
pipeline.rs | audio/DSP/FT8 pipeline setup, crossbeam channel wiring |
components.rs | QSO engine, hamlib, cqdx.io component startup |
hamlib.rs | rigctld process management and TCP connection |
health.rs | health checks and performance stats |
shutdown.rs | graceful shutdown, task join |
wav_playback.rs | WAV file playback mode for offline testing |
util.rs | shared utilities (linear resampler, etc.) |
Communication model: crossbeam channels carry point-to-point data (audio samples,
decoded messages, waterfall frames). A MessageBus handles broadcast control events
(frequency changes, QSO state transitions, DX spots, health signals).
The core channel topology established in pipeline.rs:
audio_to_dsp_tx -> audio_to_dsp_rx (Vec<f32>, bounded 100)
dsp_to_ft8_tx -> dsp_to_ft8_rx (Vec<f32>, bounded 2)
ft8_to_tui_tx -> ft8_to_tui_rx (DecodedMessage, unbounded)
waterfall_tx -> waterfall_rx (Vec<Vec<f32>>, unbounded)
wsjtx_udp component (pancetta/src/coordinator/wsjtx_udp/{mod.rs,codec.rs}) speaks
the WSJT-X-compatible UDP protocol for GridTracker/JTAlert/logger interop, following the
same disabled-drain / enabled-task lifecycle as psk_reporter.rs. It taps into the rest
of the coordinator additively, in three places: an FT8-decode fan-out tap in ft8.rs
(gated on a wsjtx_enabled: Arc<AtomicBool>, mirroring the remote-gateway decode tap)
feeds live decodes out as Decode(2) messages and into a bounded retention ring used to
answer Replay(7); a QsoManager::subscribe() broadcast subscriber — the third
subscriber alongside the ADIF writer and the upload subscriber — turns each completed
QSO into QSOLogged(5) + LoggedADIF(12); and an inbound-dispatch gate on the same UDP
socket processes Reply/HaltTx/Replay requests only after they pass the fail-closed
source/consent checks described in docs/DECISIONS/remote-operation.md (a GridTracker
double-click becomes a QsoMessage::StartQso { remote_origin: true }, which routes
through the same TxOrigin::Remote arm-gating every other remote-TX path uses). None of
these taps touch an existing →Tui send path — pancetta-tui behavior stays
byte-identical whether or not [network.wsjtx_udp] is enabled.
Key Abstractions
WorkedStationLookup (pancetta-qso)
Trait interface used by PriorityScorer for synchronous station queries: duplicate
detection, rarity lookup, and needed DXCC/grid checks. Decouples scoring logic from
the coordinator's data sources.
pancetta_qso::priority::WorkedStationLookup
- is_duplicate(callsign, band) -> bool
- get_rarity(callsign) -> f64
- is_needed_dxcc(entity) -> bool
- is_needed_grid(grid) -> bool
PriorityScorer (pancetta-qso)
Takes a slice of DecodedMessage plus a &dyn WorkedStationLookup, returns a
priority-ranked station list. Scoring weights: needed DXCC > needed grid > POTA/SOTA
rarity score > general activity. Applies duplicate suppression and failure backoff. Configured via
pancetta-config.
AutonomousOperator (pancetta-qso)
Decision engine operating in one of three modes:
- Hunt: pounce on rare stations identified by
PriorityScorer - CQ: call CQ and answer inbound callers by priority
- Hybrid: hunt when rare targets are present, CQ otherwise
Manages per-QSO state machines (CALLING -> EXCHANGING -> CONFIRMING -> COMPLETE). Hands off completed exchanges to the QSO log.
SmartFrequencyAllocator (pancetta-qso)
Selects TX audio frequency for each new QSO using 7 soft-scored criteria: avoid QRM from active signals, maintain minimum spacing between simultaneous TX streams, prefer clear channels, align with band segment conventions. Enables parallel QSOs within a single 15-second slot.
TX Slot Scheduler (pancetta/src/coordinator/tx.rs)
TransmitRequest carries an Option<SlotParity> set from the latched QSO metadata
(or None for unsolicited CQ); the scheduler picks the next opposite-parity slot and
uses silent-pad / cursor-offset to align audio to the slot boundary. Late-start
tolerance is governed by [station].tx_late_max_ms (default 8 000 ms). See
docs/superpowers/specs/2026-04-27-dx-slot-aware-tx-design.md. Audio is emitted
with silent samples padded in front (early case, mstr < 500ms) or with the
modulated waveform's cursor advanced (late case, mstr <= tx_late_max_ms), so the
operator can press Space several seconds into a slot and still TX in that slot —
receivers decode via the middle and end Costas sync arrays.
CachedStationLookup (pancetta / priority_evaluator.rs)
Coordinator-level implementation of WorkedStationLookup. Holds in-memory snapshots
of worked stations (per band), recent failures, needed DXCC entities, needed grids,
rarity scores from cqdx.io, notable callsigns, and network SNR data. Refreshed
periodically by the coordinator from cqdx.io and the QSO log.
AdifLogWriter (pancetta-qso)
Writes completed QSO records to ~/.pancetta/qsos.adi in ADIF format. The file is
append-only and vendor-neutral — it can be imported directly into WSJT-X, N1MM+, LoTW,
eQSL, or any logging application that accepts ADIF. This file is the durable
source of truth; back it up.
AsyncQsoLogger (pancetta)
Coordinator-level QSO persistence layer. On each completed QSO it writes to both the
ADIF file (via AdifLogWriter) and to the sqlx-backed SQLite index
(~/.pancetta/qso.db). The index is rebuilt from the ADIF on startup when missing or
stale; it is safe to delete. The legacy sync rusqlite path is gone; pancetta-qso is
async-only via sqlx.
Migration: on the first startup after upgrade, if qso.db exists but qsos.adi
does not, the coordinator auto-exports all rows from the old database into a fresh ADIF
file before opening the new logging path.
FT8 Protocol Notes
- Slot duration: 15 seconds (TX starts at 0s or 15s boundary)
- Audio passband: ~200–3000 Hz above suppressed carrier
- Modulation: 8-GFSK, 6.25 Hz tone spacing, 12000 samples/sec
- Coding: LDPC (174,87) + 12-bit CRC
- Message types: CQ, directed (call/grid/report/RR73/73), ARRL contest, free-text
- OSD (Ordered Statistics Decoding) extends decode depth beyond standard LDPC
pancetta-ft8 is bit-exact with ft8_lib and WSJT-X (~200 tests).
Decode Pipeline Stages (Budget-Governed Anytime Decoder)
As of the 2026-07-06 decoder-speed-overhaul (spec
docs/superpowers/specs/2026-07-06-decoder-speed-overhaul-design.md), a single
decode window (Ft8Decoder::decode_window*) runs as an anytime algorithm:
run to completion it produces the same result as before this work; stopped
early under a wall-clock DecodeBudget, it still returns everything decoded
so far, in a fixed priority order. Stages, in execution order:
sync candidates ranked by sync score
|
v
S1-floor -- top-ranked candidates, decoded UNCONDITIONALLY
| (always runs -- this alone matches the pre-overhaul
| single-pass decoder's recall floor)
v
S2-rest -- remaining candidates, gated by DecodeBudget.has_time()
| (skipped once the budget is exhausted)
v
S3 -- BP escalation ladder: candidates that fail LDPC at a
| floor iteration count get a continued (not restarted)
| BP pass up to a deeper iteration count. Ships
| DISABLED (escalation_enabled=false) -- its own A/B
| data showed BP iterations are a small fraction of
| total decode cost (Costas sync + FFT dominate), so
| enabling it wasn't worth it. KNOWN LIMITATION: when
| enabled, escalates in candidate sync-score-rank order,
| not by a global most-promising-first ranking -- see
| CLAUDE.md's decoder-speed-overhaul bullet.
v
S4-cross-cycle -- cross-cycle joint decode, budget-gated
v
S5-multipass -- multi-pass residual re-decode, budget-gated
v
S6-joint-pair -- joint-pair (dual-miss) decode, budget-gated
v
S7-a7 -- AP/a7 injection pass, budget-gated
|
v
shared finalize tail (dedup, CRC, message parsing)
Each of S2-S7 is checkpointed at its entry: if DecodeBudget::has_time() is
false, the stage is skipped (recorded in DecodeBudgetReport.stages for
telemetry) rather than run. Tests, CI, and the research harness always use
DecodeBudget::unlimited() — no stage is ever skipped on the eval path, so
recall measurements stay deterministic and comparable across runs.
In production, the coordinator seeds a real wall-clock budget into a shared
decode_effort_budget_ms atomic from the operator's decode-effort preset
([decoder] config section, or the live e TUI key): Eco (1ms, floor-only),
Standard (250ms), Deep (1000ms), Max (0/unlimited), or Auto (derived
from the probed hardware tier — see pancetta/src/coordinator/effort.rs).
This mapping subsumes the older per-hardware-tier Ft8Config field
rewrites (apply_tier no longer touches Ft8Config directly).
Known Gaps
- Grid "needed" set is never populated. cqdx.io has no
entities/needed-gridsendpoint yet;is_needed_gridreturnsfalsewhen the local set is empty so the priority weight doesn't inflate. is_duplicatechecks callsign + audio frequency proximity within a configurable time window, but doesn't yet partition by band — a station worked on 20m won't be flagged as a duplicate when worked again on 40m. Set[duplicate_checking].check_frequency = truefor band-aware dedup until this is fixed natively.- cqdx.io
GET /api/v1/spots?live=trueresponse envelope key (groups) is unverified against the live API. A gated live test exists:CQDX_TOKEN=pat_xxx cargo test -p pancetta-cqdx test_live_spots_envelope -- --ignored --nocapture.
Recent Milestones
- Phase 1 — Loopback QSO. Full CQ-to-73 exchange through the encode → modulate → decode pipeline, with state-machine tests.
- Phase 2 — Autonomous operator + priority engine. Configurable weighted scoring, POTA/SOTA detection, hunt/CQ/hybrid modes.
- Phase 3 — Multi-stream TX.
SmartFrequencyAllocatorselects audio frequencies; up to N parallel QSOs in one slot. - Phase 4 — Hardware integration (complete, 2026-04-26). hamlib CAT control via rigctld short-form commands; first real-rig TX validated on a Yaesu FTdx10 with clean ALC and tail-end PSKReporter spots across NA + EU.
- Phase 5 (current) — Full autonomous QSO loop on real hardware: CQ → grid → report → RR73 end-to-end without operator intervention.