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:

FileRole
mod.rsApplicationCoordinator struct, startup sequencing
pipeline.rsaudio/DSP/FT8 pipeline setup, crossbeam channel wiring
components.rsQSO engine, hamlib, cqdx.io component startup
hamlib.rsrigctld process management and TCP connection
health.rshealth checks and performance stats
shutdown.rsgraceful shutdown, task join
wav_playback.rsWAV file playback mode for offline testing
util.rsshared 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-grids endpoint yet; is_needed_grid returns false when the local set is empty so the priority weight doesn't inflate.
  • is_duplicate checks 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 = true for band-aware dedup until this is fixed natively.
  • cqdx.io GET /api/v1/spots?live=true response 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. SmartFrequencyAllocator selects 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.